[{"content":"문제의 시작 Pod 리소스 제한 클러스터에 Pod를 배포할 때는 CPU와 메모리 사용량에 제한을 두는 경우가 일반적이다.\n이는 어찌 보면 당연한 것인데,\n이런 제한을 두지 않는다면 Pod는 자신이 동작하는 호스트의 기둥 뿌리를 모조리 뽑아 써먹으려고 할 것이고\n나아가, 다른 Pod가 해당 노드에 스케줄링되는 데 지대한 악영향을 끼칠 것이기 때문이다.\n적용법은 대단히 쉽다.\n다음 예시와 같이 Pod의 spec에 명시해 주면 된다.\n1 2 3 4 5 6 7 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 200m memory: 256Mi OOMKilled(Out of Memory) 20대 Pod쪽이들 사망 원인 1위\n문제는 Pod라는 녀석이 생각 외로 멍청하다는 데 있다.\nOOMKilled라고 하면 호스트 전체 메모리 부족 이후 커널 OOM killer가 개입하는 경우도 있겠으나,\n대개 Kubernetes Pod에서는 manifest에 적어 둔 메모리 limits(cgroup 한도)를 넘겼을 때 해당 컨테이너 프로세스가 강제 종료되고 자원이 회수되는 흐름을 말한다고 보면 된다.\n난처한 점은, 메모리 한도 초과로 발생한 OOMKilled는 일반적인 Pod 종료와 달리 커널 OOM killer가 프로세스를 즉시 SIGKILL로 종료시키는 경우가 많아 terminationGracePeriodSeconds, preStop, SIGTERM 기반의 graceful shutdown 로직이 동작하지 않을 수 있다는 것이다.\n따라서 OOM 대응의 핵심은 종료 절차 설계보다는 메모리 사용량 자체를 한도 안으로 유지하는 데 있다.\n예를 들어 requests/limits 재조정, 메모리 누수 제거, 런타임별 메모리 옵션 튜닝, 오토스케일링 정책 보완 같은 접근이 더 직접적이다.\nPod가 인식하는 리소스 한계치 그런데 생각해보면 좀 이상하다.\n당신이 메모리 8GB짜리 저렴이 노트북 하나를 구매했다고 가정해보자.\n8GB 메모리 사용량을 약간 초과할 만한 작업을 명령할 경우 노트북은 어떻게 반응할까?\nPod들이 그러하듯, 메모리 사용량 초과로 인해 OS가 통째로 먹통이 될까?\n구형 PC라면 진짜 블루 스크린이 뜰 수도 있겠으나😱, 대개의 경우 OS가 페이지 회수나 스왑 같은 메커니즘으로 버티면서 성능이 먼저 저하되고 시스템이 즉시 셧다운되는 경우는 드물다.\n요컨대 야근 10분 더 시킨다고 피곤해 하고 툴툴 댈 수는 있어도 그 자리에서 사표 내고 도망치지는 않는단 소리다.\n그럼 Pod들은 왜 틈만 나면 지정된 리소스 제한을 넘으려고 하는 것인가?\n결론부터 말하자면, Pod들이 기본적으로 spec에 적힌 리소스 제한과 무관하게\n자신이 올라간 노드(호스트)의 리소스를 자신의 리소스로 착각하고 있기 때문에 발생하는 현상이다.\n더도 말고 덜도 말고 다음 예시처럼 Pod를 하나 배포해서 확인해보자.\n1 kubectl create namespace test 우선 test라는 네임스페이스를 하나 만든다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # test.pod.yml apiVersion: v1 kind: Pod metadata: name: test namespace: test spec: containers: - command: - sleep - infinity image: ubuntu name: test resources: limits: cpu: 200m memory: 256Mi requests: cpu: 100m memory: 128Mi CPU 200m, 메모리 256Mi로 제한된 Ubuntu Pod이다.\n1 kubectl apply -f test.pod.yml 배포가 되었다면 다음 명령어로 Pod 컨테이너 안에서 free가 보여주는 메모리(/proc/meminfo 등을 읽은 값)를 확인해보자.\n1 2 3 4 5 kubectl exec test -n test -- free -h total used free shared buff/cache available Mem: 31Gi 17Gi 346Mi 179Mi 14Gi 14Gi Swap: 0B 0B 0B 이상하게도 limits.memory는 256Mi로 두었는데, 컨테이너 안에서 free로 보면 total이 31Gi로 나온다.\n31Gi(32GB)는 이 Pod가 스케줄된 노드의 물리 메모리 규모와 맞아떨어지는 값으로, 애플리케이션이 읽는 /proc 쪽 정보가 cgroup 한도가 아니라 노드 전체 스케일로 노출되어 있다는 뜻이다.\n해결방안 LXCFS (Linux Container File System) LXCFS는 리눅스 커널의 한계를 우회하기 위해 만들어진 간단한 유저스페이스 파일시스템이다.\n핵심 아이디어는 컨테이너 내부에서 읽는 /proc 정보를 cgroup 인식 값으로 바꿔서 보여주는 데 있다.\nLXCFS가 제공하는 핵심 기능은 크게 두 가지다.\n/proc의 일부 파일 위에 bind-mount로 덮어쓸 수 있는 파일 세트 제공 컨테이너 안에서 free, top 같은 도구가 읽는 값이 호스트 전체 기준이 아니라, 컨테이너 관점(cgroup 기준)에 가깝게 보이도록 만든다. 컨테이너 인지형 cgroupfs 유사 트리 제공 컨테이너 내부 프로세스가 cgroup 관련 정보를 다룰 때 더 일관된 뷰를 얻을 수 있다. 즉, LXCFS는 \u0026ldquo;리소스 제한은 걸려 있는데 앱이 보는 시스템 정보는 호스트 전체처럼 보이는\u0026rdquo; 간극을 줄여서,\n컨테이너를 더 독립된 시스템처럼 느끼게 해주는 보정 레이어라고 보면 된다.\n참고: cgroup namespace 도입 이후 일부 초기 목적은 줄어들었고, 현재는 /proc 마스킹을 통해 컨테이너의 체감 독립성을 높이는 쪽에 더 초점이 맞춰져 있다.\nHelm을 통해 배포 lxcfs-on-kubernetes 프로젝트를 사용하면 LXCFS를 클러스터에 비교적 간단하게 배포할 수 있다.\n기본 흐름은 Helm chart 설치 -\u0026gt; 네임스페이스 라벨링 -\u0026gt; 워크로드 재기동 후 확인 순서다.\n사전 준비 차트 Repo에 적힌 사전 요구사항은 다음과 같다(사용하는 차트 버전에 따라 달라질 수 있으니 설치 전 릴리스 문서를 함께 확인하자).\nKubernetes v1.19+ Helm v3 cert-manager v1.2+ Helm 리포지토리 등록 1 2 helm repo add lxcfs-on-kubernetes https://cndoit18.github.io/lxcfs-on-kubernetes/ helm repo update LXCFS 설치 1 helm upgrade --install lxcfs lxcfs-on-kubernetes/lxcfs-on-kubernetes -n lxcfs --create-namespace 혹은 사전에 네임스페이스를 생성해도 좋고, 그냥 kube-system 네임스페이스에 설치해도 무방하다.\nLXCFS 주입 대상 네임스페이스 라벨링 아래 예시는 test 네임스페이스에 라벨로 LXCFS 마운트 주입을 활성화하는 방법이다.\n1 kubectl label namespace test mount-lxcfs=enabled 이미 실행 중인 Pod에는 즉시 반영되지 않을 수 있으므로, 대상 워크로드를 재시작해서 새 Pod가 뜨도록 맞춰 주는 편이 안전하다.\n적용 확인 라벨링한 네임스페이스에 테스트 Pod를 띄운 뒤, 이전과 동일하게 free -h 또는 /proc/meminfo를 확인한다. LXCFS가 정상 동작하면 애플리케이션이 보는 리소스 정보가 컨테이너(cgroup) 관점으로 더 가깝게 보정된다.\n참고: 차트/버전별로 마운트 경로나 검증 로직이 달라질 수 있으니, 실제 운영 적용 전에는 사용하는 릴리즈 문서의 Breaking Changes를 꼭 확인하자.\n1 2 3 4 5 kubectl exec test -n test -- free -h total used free shared buff/cache available Mem: 256Mi 1.1Mi 254Mi 0B 129Ki 254Mi Swap: 0B 0B 0B 재배포 후 다시 확인해보면 메모리가 의도한 대로 출력되는 것을 확인할 수 있다.\nTroubleshooting Pod에 Volume 개별 마운트 방식 가장 쉬운 적용 방법은 위에서 언급한 것처럼 대상 네임스페이스에 mount-lxcfs=enabled 라벨을 다는 것이지만, 보다 선택적/제한적으로 적용하고자 할 경우에는 개별 파일을 Volume으로 마운트해 구현할 수 있다.\n우선 test 네임스페이스의 해당 라벨을 제거해 준다.\n1 kubectl label namespace test mount-lxcfs- lxcfs-on-kubernetes Helm Chart는 클러스터 내 모든 노드에 대해 DaemonSet으로 LXCFS를 설치한다.\n기본 설치 경로는 /var/lib/lxcfs-on-k8s/lxcfs이다. Values를 조절해서 경로를 바꿀 수 있으나,\n기본값으로 두었다고 가정하고 Pod Spec을 다음과 같이 수정해주자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 # test.pod.yml apiVersion: v1 kind: Pod metadata: name: test spec: containers: - command: - sleep - infinity image: ubuntu name: test resources: limits: cpu: 200m memory: 256Mi requests: cpu: 100m memory: 128Mi volumeMounts: - mountPath: /proc/meminfo mountPropagation: None name: lxcfs-meminfo readOnly: true - mountPath: /proc/cpuinfo mountPropagation: None name: lxcfs-cpuinfo readOnly: true - mountPath: /proc/stat mountPropagation: None name: lxcfs-stat readOnly: true - mountPath: /proc/loadavg mountPropagation: None name: lxcfs-loadavg readOnly: true - mountPath: /proc/uptime mountPropagation: None name: lxcfs-uptime readOnly: true volumes: - hostPath: path: /var/lib/lxcfs-on-k8s/lxcfs/proc/meminfo type: File name: lxcfs-meminfo - hostPath: path: /var/lib/lxcfs-on-k8s/lxcfs/proc/cpuinfo type: File name: lxcfs-cpuinfo - hostPath: path: /var/lib/lxcfs-on-k8s/lxcfs/proc/stat type: File name: lxcfs-stat - hostPath: path: /var/lib/lxcfs-on-k8s/lxcfs/proc/loadavg type: File name: lxcfs-loadavg - hostPath: path: /var/lib/lxcfs-on-k8s/lxcfs/proc/uptime type: File name: lxcfs-uptime 수정된 test.pod.yml 파일을 배포한다.\n1 kubectl apply -f test.pod.yml 이제 다시 test Pod의 메모리 할당 정보를 확인해보자.\n1 2 3 4 5 kubectl exec test -n test -- free -h total used free shared buff/cache available Mem: 256Mi 1.1Mi 254Mi 0B 129Ki 254Mi Swap: 0B 0B 0B 노드(호스트) FUSE 마운트 손상 시 해결 노드(호스트)에서 LXCFS는 FUSE로 마운트되어 있다.\n경로는 앞서 언급한 바와 같이 /var/lib/lxcfs-on-k8s/lxcfs\n간혹 해당 경로의 FUSE 마운트가 깨지는 경우가 있는데, 노드를 강제로 리부트할 때 종종 발생한다.\n마운트 손상 시 lxcfs-on-kubernetes Helm Chart는 이를 자동으로 복구하는 기능이 없어 별도의 DaemonSet을 추가했다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 # lxcfs-mount-recovery.daemonset.yml apiVersion: apps/v1 kind: DaemonSet metadata: labels: app.kubernetes.io/name: lxcfs-mount-recovery name: lxcfs-mount-recovery-daemon-set namespace: lxcfs spec: selector: matchLabels: app.kubernetes.io/name: lxcfs-mount-recovery template: metadata: labels: app.kubernetes.io/name: lxcfs-mount-recovery spec: automountServiceAccountToken: true containers: # 60초(ENV에 명시)마다 1번씩 노드(호스트)의 LXCFS FUSE 마운트 상태를 확인한 후 # 비정상일 경우 마운트를 해제한다. # 마운트가 해제되면 LXCFS Helm Chart가 자동으로 다시 마운트를 진행한다. - args: - |- while true; do OUT=$(nsenter -t 1 -m -- stat \u0026#34;$MOUNT_PATH\u0026#34; 2\u0026gt;\u0026amp;1 || true) if echo \u0026#34;$OUT\u0026#34; | grep -qiE \u0026#39;transport endpoint|not connected|stale\u0026#39;; then echo \u0026#34;$(date +%Y-%m-%dT%H:%M:%SZ) broken lxcfs mount at $MOUNT_PATH, lazy unmount\u0026#34; \u0026gt;\u0026amp;2 nsenter -t 1 -m -- umount -l \u0026#34;$MOUNT_PATH\u0026#34; 2\u0026gt;/dev/null || true fi sleep \u0026#34;$INTERVAL\u0026#34; done command: - /bin/sh - -c env: # 확인 주기 - name: INTERVAL value: \u0026#39;60\u0026#39; # LXCFS 호스트 마운트 경로 - name: MOUNT_PATH value: /var/lib/lxcfs-on-k8s/lxcfs image: alpine name: mount-recovery resources: limits: cpu: 100m memory: 64Mi requests: cpu: 10m memory: 32Mi securityContext: privileged: true hostPID: true priorityClassName: system-node-critical tolerations: - effect: NoExecute operator: Exists - effect: NoSchedule operator: Exists updateStrategy: type: RollingUpdate 작성한 매니페스트를 적용해 데몬셋을 배포한다.\n1 kubectl apply -f lxcfs-mount-recovery.daemonset.yml 배포 후에는 다음 명령으로 각 노드에 Pod가 정상적으로 뜨는지 확인한다.\n1 kubectl get pods -n lxcfs -l app.kubernetes.io/name=lxcfs-mount-recovery -o wide ","date":"2025-12-02T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/install-lxcfs-to-k8s/images/cover_hu_985722972596e7b8.png","permalink":"https://blog.ayteneve93.com/p/dev/install-lxcfs-to-k8s/","title":"LXCFS로 우리 Pod쪽이를 똑똑하게 키우는 법"},{"content":"연관 포스트 \u0026ldquo;Istio를 활용해 다중 클러스터에 Service Mesh 구성하기\u0026rdquo; 들어가기 앞서 며칠 전에 건강검진을 받고 왔다.\n피도 뽑고, 혈압도 재고, 환청이 들리진 않는지, 눈이 멀진 않았는지 등등 이런저런 점검을 수행한다.\n행여나 무슨 큰 병이 나기 전에 미리 체크하고 대응할 여지를 만들어 주기 위함이다.\n서버도 마찬가지로 주기적인 건강검진이 필요하다.\n클러스터 안에서는 동시에 수많은 로직이 동작하고,\n각 워크로드는 클러스터 내 다른 서비스들과 협력하며 크고 복잡한 작업을 수행한다.\n앱이 1~2개 정도라면 직접 kubectl logs나 kubectl top 커맨드로 안에서 무슨 일이 일어나고 있는지,\n리소스는 얼마나 소모 중인지 파악할 수 있지만, 그 수가 많아지면 인간이 감당하는 것이 불가능해진다.\n안색만 살피면 무슨 병을 앓고 있는지 알 수 있을 정도로 실력이 뛰어난 의사가 아니라면,\nX-Ray, CT와 같은 전문적인 장비와 거기서 산출된 일련의 차트 정보를 바탕으로 판단하는 것이 합리적일 것이다.\n이번 포스트에서는 Prometheus, Loki, Kiali, Grafana를 통해 멀티 클러스터 환경에서\n기본적인 모니터링 스택을 구축하는 방법에 대해 공유한다.\n시스템 개요 전체 시스템의 개요는 위와 같다.\n설치되는 컴포넌트 수가 많은 만큼 최종 결과물이 굉장히 다양할 수밖에 없다. 예를 들어 총 5개의 옵션이 있는 3개의 선택지가 있다면, 발생할 수 있는 경우의 수는 125가지나 된다.\n당연히 그 모든 경우에 대해 빠짐없이 기술하는 것은 한계가 있다.\nMulti Cluster 모니터링 스택에 대해 최대한 간략하게 정의한 요구사항은 다음과 같다.\n2개 이상의 클러스터에서\n메트릭 / 서비스 메시 / 애플리케이션 로그 데이터를 수집하여\n통합된 대시보드(Grafana, Kiali)로 확인 가능할 것\nELK 대신 Loki를 택한 이유 이번 작업에서는 ELK(Elasticsearch, Logstash, Kibana) 대신 Loki를 사용했다.\n이유는 단순하다.\nELK의 리소스 소모량이 감당이 안 된다.\nOracle에서 무료로 사용 중인 클러스터에 ELK를 설치했다면 배보다 배꼽이 더 커지는 상황이 연출될 것이다.\n여러 대체재를 알아봤는데, Loki가 k8s에 경량으로 쓰기 좋다는 소식을 들었다.\nElasticsearch의 검색 기능이 매우 훌륭하긴 하나, 샤드 관리하기도 복잡하고 저장 공간도 엄청나게 잡아먹는다.\nLogstash 역시 CPU/메모리 사용량이 굉장히 높다.\nLoki는 Elasticsearch와 달리 데이터를 인덱싱하지 않고 레이블을 기반으로 저장한다.\n인덱스 저장 공간이 별도로 필요한 것이 아니므로 저장 공간을 크게 절약할 수 있다.\n당연히 샤드 관리 같은 것도 필요 없다.\n공통 네임스페이스 구성 각 섹션의 앞에는 \u0026ldquo;Istio를 활용해 다중 클러스터에 Service Mesh 구성하기\u0026rdquo; 포스트에서와 마찬가지로\n어떤 Kubernetes Context에서 작업하는 것인지 명시했다.\n사용하는 k8s가 1개라면 Context : Remote 레이블이 붙은 섹션은 무시하도록 하자.\n우선 공통적으로 컴포넌트가 배포될 Namespace는 monitoring이다.\n각 클러스터에 네임스페이스를 만들어준다.\nㅤ⚠️ Context : Primaryㅤ\n1 kubectl create namespace monitoring ㅤ⚠️ Context : Remoteㅤ\n1 kubectl create namespace monitoring Loki \u0026amp; Promtail Primary에 Loki \u0026amp; Promtail 설치 ㅤ⚠️ Context : Primaryㅤ\nPrimary Cluster에 Loki와 Promtail을 설치한다.\n배포는 \u0026ldquo;loki-stack\u0026rdquo; Helm 차트를 사용한다.\nHelm Chart 가져오기 1 helm repo add grafana https://grafana.github.io/helm-charts Helm Values 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 # Primary-Loki-Stack.values.yml # Grafana 설정 grafana: # 다른 Helm 차트를 통해 설치할 것이므로 비활성화 enabled: false # Loki 설정 # 로그 데이터가 저장되는 일종의 데이터베이스다 # ELK의 Elasticsearch와 유사 loki: config: limits_config: ingestion_burst_size_mb: 200 ingestion_rate_mb: 100 max_streams_per_user: 10000 enabled: true persistence: accessModes: - ReadWriteOnce enabled: true size: 20Gi storageClassName: \u0026lt;사용할 StorageClass 명\u0026gt; resources: limits: cpu: 500m memory: 1024Mi requests: cpu: 300m memory: 512Mi # Promtail 설정 # 로그 수집기다 # ELK의 Logstash와 유사 promtail: enabled: true config: clients: - external_labels: # Primary Cluster의 이름을 적어주자 # 단일 k8s일 경우 clients 설정 전체를 생략해도 된다 cluster: oke url: http://loki-stack.monitoring.svc.cluster.local:3100/loki/api/v1/push resources: limits: cpu: 200m memory: 256Mi requests: cpu: 100m memory: 128Mi Helm 차트 배포 1 2 3 helm install loki-stack grafana/loki-stack \\ -n monitoring \\ -f Primary-Loki-Stack.values.yml 배포 상태 확인 1 2 3 kubectl get all \\ -l app.kubernetes.io/instance=loki-stack \\ -n monitoring 다음과 같이 출력되면 정상이다.\n1 2 3 4 5 6 NAME READY STATUS RESTARTS AGE pod/loki-stack-promtail-gckgr 1/1 Running 0 4d4h pod/loki-stack-promtail-tnnrz 1/1 Running 0 4d4h NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE daemonset.apps/loki-stack-promtail 2 2 2 2 2 \u0026lt;none\u0026gt; 4d4h Primary Loki 외부 진입점 설정 ㅤ⚠️ Context : Primaryㅤ\nIngress 혹은 VirtualService 등을 사용해 Loki 외부 진입점을 만들어야 한다.\n이는 Remote Cluster에 추후 설치할 Promtail이 Primary의 Loki에 데이터를 밀어넣을 수 있도록 하기 위함이다.\n단일 클러스터를 운영 중이라면 이 부분은 생략하도록 하자.\n이 예시에서는 VirtualService를 통해 외부로 노출했다.\nVirtualService 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # Primary-Loki-VS.yml apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: monitoring-loki-virtual-service namespace: monitoring spec: gateways: - \u0026lt;사용할 Istio Gateway. 가령, istio-system/my-istio-gateway\u0026gt; hosts: - \u0026lt;사용할 Domain Host. 가령, my-primary-loki.example.com\u0026gt; http: - match: - port: 3100 route: - destination: host: loki-stack port: number: 3100 VirtualService 배포 1 kubectl apply -f Primary-Loki-VS.yml Remote에 Promtail 설치 ㅤ⚠️ Context : Remoteㅤ\n\u0026ldquo;Loki\u0026quot;는 이미 Primary에 설치되어 있으므로, Remote Cluster에는 Promtail만 설치하면 된다.\nPromtail 단일 컴포넌트만 배포해도 상관 없지만, 편의를 위해 동일한 Helm Chart를 사용했다.\nHelm Values 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # Remote-Loki-Stack.values.yml # Grafana 설정 grafana: # Primary와 마찬가지 이유로 Grafana 비활성화 enabled: false # Loki 설정 loki: # Primary의 Loki를 쓸 예정이므로 이것도 비활성화 enabled: false # Promtail 설정 promtail: config: clients: - external_labels: # Remote Cluster의 이름을 적어주자 cluster: workstation url: https://\u0026lt;앞서 설정한 Primary Loki의 도메인, 가령 my-primary-loki.example.com\u0026gt;/loki/api/v1/push enabled: true resources: limits: cpu: 400m memory: 1Gi requests: cpu: 200m memory: 512Mi Helm 차트 배포 1 2 3 helm install loki-stack grafana/loki-stack \\ -n monitoring \\ -f Remote-Loki-Stack.values.yml 배포 상태 확인 1 2 3 kubectl get all \\ -l app.kubernetes.io/instance=loki-stack \\ -n monitoring 다음과 같이 출력되면 정상이다.\n1 2 3 4 5 NAME READY STATUS RESTARTS AGE pod/loki-stack-promtail-22vvg 1/1 Running 15 4d4h NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE daemonset.apps/loki-stack-promtail 1 1 1 1 1 \u0026lt;none\u0026gt; 4d5h Primary와 다르게 Promtail만 설치된 모습이다.\nPrometheus \u0026amp; Grafana Primary에 Prometheus \u0026amp; Grafana 설치 ㅤ⚠️ Context : Primaryㅤ\nPrimary Cluster에 Prometheus와 Grafana를 설치한다.\n배포는 kube-prometheus-stack Helm 차트를 사용한다.\nHelm Chart 가져오기 1 helm repo add prometheus-community https://prometheus-community.github.io/helm-charts Helm Values 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 # Primary-Kube-Prometheus-Stack.values.yml # AlertManager 설정 alertmanager: # 비활성화 # 이 부분은 추후 다른 포스트를 통해 다루도록 하겠다. enabled: false # Grafana 설정 grafana: enabled: true # Primary Cluster에 배포한 Loki를 데이터 소스에 추가해주자 additionalDataSources: - access: proxy isDefault: false jsonData: maxLines: 1000 name: Loki type: loki url: http://loki-stack.monitoring.svc.cluster.local:3100 adminPassword: \u0026lt;관리자 비밀번호\u0026gt; adminUser: \u0026lt;관리자 ID\u0026gt; defaultDashboardsTimezone: Asia/Seoul persistence: accessModes: - ReadWriteOnce enabled: true finalizers: - kubernetes.io/pvc-protection size: 20Gi storageClassName: \u0026lt;사용할 StorageClass 명\u0026gt; type: sts # Grafana Dashboard 설정 # 미리 각종 Dashboard를 추가해두었다. # dashboardProviders, dashboards 모두 비워두고 추후 웹에서 추가해도 된다. dashboardProviders: dashboardproviders.yaml: apiVersion: 1 providers: - folder: Custom Node Exporter name: custom-node-exporter options: path: /var/lib/grafana/dashboards/custom-node-exporter type: file - folder: Istio name: istio options: path: /var/lib/grafana/dashboards/istio type: file - folder: Loki name: loki options: path: /var/lib/grafana/dashboards/loki type: file dashboards: custom-node-exporter: node-exporter-full: datasource: Prometheus gnetId: 1860 revision: 42 istio: istio-control-plane-dashboard: datasource: Prometheus gnetId: 7645 revision: 278 istio-mesh-dashboard: datasource: Prometheus gnetId: 7639 revision: 278 istio-performance-dashboard: datasource: Prometheus gnetId: 11829 revision: 278 istio-service-dashboard: datasource: Prometheus gnetId: 7636 revision: 278 istio-workload-dashboard: datasource: Prometheus gnetId: 7630 revision: 278 loki: logs-app: datasource: Loki gnetId: 13639 revision: 2 # Prometheus 설정 prometheus: enabled: true prometheusSpec: enableRemoteWriteReceiver: true externalLabels: cluster: oke resources: limits: cpu: \u0026#34;1\u0026#34; memory: 2Gi requests: cpu: 300m memory: 512Mi storageSpec: volumeClaimTemplate: spec: accessModes: - ReadWriteOnce resources: requests: storage: 50Gi storageClassName: \u0026lt;사용할 StorageClass 명\u0026gt; Helm 차트 배포 1 2 3 helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \\ -n monitoring \\ -f Primary-Kube-Prometheus-Stack.values.yml 배포 상태 확인 1 2 3 kubectl get all \\ -l app.kubernetes.io/instance=kube-prometheus-stack \\ -n monitoring 다음과 같이 출력되면 정상이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 NAME READY STATUS RESTARTS AGE pod/kube-prometheus-stack-grafana-0 3/3 Running 0 5d3h pod/kube-prometheus-stack-kube-state-metrics-787d55fc86-9j42s 1/1 Running 0 6d4h pod/kube-prometheus-stack-operator-79df675c88-s6rnj 1/1 Running 0 6d4h pod/kube-prometheus-stack-prometheus-node-exporter-24rdg 1/1 Running 0 6d4h pod/kube-prometheus-stack-prometheus-node-exporter-q8tcb 1/1 Running 0 6d4h NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kube-prometheus-stack-grafana ClusterIP 10.96.147.47 \u0026lt;none\u0026gt; 80/TCP 6d4h service/kube-prometheus-stack-grafana-headless ClusterIP None \u0026lt;none\u0026gt; 9094/TCP 5d21h service/kube-prometheus-stack-kube-state-metrics ClusterIP 10.96.216.187 \u0026lt;none\u0026gt; 8080/TCP 6d4h service/kube-prometheus-stack-operator ClusterIP 10.96.244.110 \u0026lt;none\u0026gt; 443/TCP 6d4h service/kube-prometheus-stack-prometheus ClusterIP 10.96.134.158 \u0026lt;none\u0026gt; 9090/TCP,8080/TCP 6d4h service/kube-prometheus-stack-prometheus-node-exporter ClusterIP 10.96.255.80 \u0026lt;none\u0026gt; 9100/TCP 6d4h NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE daemonset.apps/kube-prometheus-stack-prometheus-node-exporter 2 2 2 2 2 kubernetes.io/os=linux 6d4h NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/kube-prometheus-stack-kube-state-metrics 1/1 1 1 6d4h deployment.apps/kube-prometheus-stack-operator 1/1 1 1 6d4h NAME DESIRED CURRENT READY AGE replicaset.apps/kube-prometheus-stack-kube-state-metrics-787d55fc86 1 1 1 6d4h replicaset.apps/kube-prometheus-stack-operator-79df675c88 1 1 1 6d4h NAME READY AGE statefulset.apps/kube-prometheus-stack-grafana 1/1 5d21h Primary Prometheus 외부 진입점 설정 Loki와 마찬가지로 Prometheus도 Ingress 혹은 VirtualService로 외부 진입점을 만들어야 한다.\n이 예시에서는 VirtualService로 작성했다.\nVirtualService 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # Primary-Prometheus-VS.yml apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: monitoring-prometheus-virtual-service namespace: monitoring spec: gateways: - \u0026lt;사용할 Istio Gateway. 가령, istio-system/my-istio-gateway\u0026gt; hosts: - \u0026lt;사용할 Domain Host. 가령, my-primary-prometheus.example.com\u0026gt; http: - match: - port: 9090 route: - destination: host: kube-prometheus-stack-prometheus port: number: 9090 VirtualService 배포 1 kubectl apply -f Primary-Prometheus-VS.yml Primary에 Istiod Service Monitor 설치 ㅤ⚠️ Context : Primaryㅤ\nPrometheus가 Istio 제어 평면의 메트릭을 수집할 수 있도록 Service Monitor 배포\nServiceMonitor 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # Primary-Istiod-ServiceMonitor.yml apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: labels: monitoring: istio-control-plane release: kube-prometheus-stack name: monitoring-istiod-service-monitor namespace: monitoring spec: endpoints: - interval: 15s port: http-monitoring jobLabel: istiod namespaceSelector: matchNames: - istio-system selector: matchExpressions: - key: istio operator: In values: - pilot targetLabels: - app ServiceMonitor 배포 1 kubectl apply -f Primary-Istiod-ServiceMonitor.yml Primary에 Istio Envoy Pod Monitor 설치 ㅤ⚠️ Context : Primaryㅤ\nPrometheus가 Istio Envoy Sidecar의 메트릭 정보를 수집할 수 있도록 Pod Monitor 배포\nPodMonitor 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # Primary-Istio-Envoy-PodMonitor.yml apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: labels: monitoring: istio-envoy-proxies release: kube-prometheus-stack name: monitoring-istio-envoy-stats-pod-monitor namespace: monitoring spec: jobLabel: envoy-stats namespaceSelector: any: true podMetricsEndpoints: - interval: 15s path: /stats/prometheus port: http-envoy-prom selector: matchExpressions: - key: service.istio.io/canonical-revision operator: Exists PodMonitor 배포 1 kubectl apply -f Primary-Istio-Envoy-PodMonitor.yml Remote에 Prometheus 설치 ㅤ⚠️ Context : Remoteㅤ\n\u0026ldquo;Grafana\u0026quot;는 이미 Primary에 설치되어 있으므로, Remote Cluster에는 Prometheus만 설치하면 된다.\nHelm Values 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # Remote-Kube-Prometheus-Stack.values.yml # AlertManager 설정 alertmanager: # 비활성화 enabled: false # Grafana 설정 grafana: # 비활성화 enabled: false # Prometheus 설정 prometheus: enabled: true prometheusSpec: externalLabels: # Remote Cluster의 이름을 적어주자 cluster: workstation remoteWrite: - url: https://\u0026lt;앞서 설정한 Primary Prometheus의 도메인, 가령 my-primary-prometheus.example.com\u0026gt;/api/v1/write resources: limits: cpu: \u0026#39;1\u0026#39; memory: 2Gi requests: cpu: 500m memory: 1024Mi Helm 차트 배포 1 2 3 helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \\ -n monitoring \\ -f Remote-Kube-Prometheus-Stack.values.yml 배포 상태 확인 1 2 3 kubectl get all \\ -l app.kubernetes.io/instance=kube-prometheus-stack \\ -n monitoring 다음과 같이 출력되면 정상이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 NAME READY STATUS RESTARTS AGE pod/kube-prometheus-stack-kube-state-metrics-787d55fc86-dnlzf 1/1 Running 15 4d5h pod/kube-prometheus-stack-operator-79df675c88-xpdxw 1/1 Running 15 4d5h pod/kube-prometheus-stack-prometheus-node-exporter-sjwml 1/1 Running 15 4d5h NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kube-prometheus-stack-kube-state-metrics ClusterIP 10.152.183.231 \u0026lt;none\u0026gt; 8080/TCP 4d5h service/kube-prometheus-stack-operator ClusterIP 10.152.183.227 \u0026lt;none\u0026gt; 443/TCP 4d5h service/kube-prometheus-stack-prometheus ClusterIP 10.152.183.185 \u0026lt;none\u0026gt; 9090/TCP,8080/TCP 4d5h service/kube-prometheus-stack-prometheus-node-exporter ClusterIP 10.152.183.205 \u0026lt;none\u0026gt; 9100/TCP 4d5h NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE daemonset.apps/kube-prometheus-stack-prometheus-node-exporter 1 1 1 1 1 kubernetes.io/os=linux 4d5h NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/kube-prometheus-stack-kube-state-metrics 1/1 1 1 4d5h deployment.apps/kube-prometheus-stack-operator 1/1 1 1 4d5h NAME DESIRED CURRENT READY AGE replicaset.apps/kube-prometheus-stack-kube-state-metrics-787d55fc86 1 1 1 4d5h replicaset.apps/kube-prometheus-stack-operator-79df675c88 1 1 1 4d5h Remote에 Istio Envoy Pod Monitor 설치 ㅤ⚠️ Context : Remoteㅤ\nPrometheus가 Istio Envoy Sidecar의 메트릭 정보를 수집할 수 있도록 Pod Monitor 배포\nPodMonitor 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # Remote-Istio-Envoy-PodMonitor.yml apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: labels: monitoring: istio-envoy-proxies release: kube-prometheus-stack name: monitoring-istio-envoy-stats-pod-monitor namespace: monitoring spec: jobLabel: envoy-stats namespaceSelector: any: true podMetricsEndpoints: - interval: 15s path: /stats/prometheus port: http-envoy-prom selector: matchExpressions: - key: service.istio.io/canonical-revision operator: Exists PodMonitor 배포 1 kubectl apply -f Remote-Istio-Envoy-PodMonitor.yml Primary Grafana 외부 진입점 설정 ㅤ⚠️ Context : Primaryㅤ\n이제 Grafana의 외부 진입점을 설정해 웹 페이지를 통해 확인해볼 차례이다.\nVirtualService 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Primary-Grafana-VS.yml apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: monitoring-grafana-virtual-service namespace: monitoring spec: gateways: - \u0026lt;사용할 Istio Gateway. 가령, istio-system/my-istio-gateway\u0026gt; hosts: - \u0026lt;사용할 Domain Host. 가령, my-primary-grafana.example.com\u0026gt; http: - route: - destination: host: kube-prometheus-stack-grafana port: number: 80 VirtualService 배포 1 kubectl apply -f Primary-Grafana-VS.yml Grafana 페이지 접속 Ingress 혹은 VirtualService로 설정한 호스트에 접속해보자.\nPrimary Cluster에 kube-prometheus-stack 배포 시,\nGrafana의 대시보드 값을 넣어줬다면, 설정한 대시보드가 미리 프로비저닝되어 있을 것이다.\nDashboard -\u0026gt; Custom Node Exporter -\u0026gt; Node Exporter Full로 접속해보자\n두 클러스터의 정보를 모두 확인 가능하다면 기본적인 Grafana 설정은 완료되었다.\nKiali Primary에 Kiali Operator 설치 ㅤ⚠️ Context : Primaryㅤ\nKiali는 Operator Pattern을 사용하는 것이 일반적이다.\n별도 네임스페이스에 kiali-operator를 helm으로 설치하고,\n배포된 CRD와 Operator를 통해 monitoring 네임스페이스에 실제 Kiali CR을 배포한다.\n네임스페이스 생성 1 kubectl create namespace kiali-operator Helm Chart 가져오기 1 helm repo add kiali https://kiali.org/helm-charts Helm Values 정의 1 2 3 # Primary-Kiali-Operator.values.yml cr: create: false Helm 차트 배포 1 2 3 helm install kiali-operator kiali/kiali-operator \\ -n kiali-operator \\ -f Primary-Kiali-Operator.values.yml 배포 상태 확인 1 2 3 kubectl get all \\ -l app.kubernetes.io/instance=kiali-operator \\ -n kiali-operator 다음과 같이 출력되면 정상이다.\n1 2 3 4 5 6 7 8 NAME READY STATUS RESTARTS AGE pod/kiali-operator-8647f4d94c-x75fp 1/1 Running 0 5d18h NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/kiali-operator 1/1 1 1 5d18h NAME DESIRED CURRENT READY AGE replicaset.apps/kiali-operator-8647f4d94c 1 1 1 5d18h Remote에 Kiali용 Service Account 생성 ㅤ⚠️ Context : Remoteㅤ\nPrimary에 설치되는 Kiali가 Remote Cluster에 접근할 수 있도록\nRemote에 Service Account와 Cluster Role, Cluster Role Binding을 만들어줘야 한다.\n이 부분은 하나의 yml 파일로 정리하도록 하겠다.\nManifest 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 # Remote-Kiali-Account-Info.yml --- apiVersion: v1 kind: ServiceAccount metadata: name: kiali-remote-access-service-account namespace: istio-system secrets: - name: kiali-remote-access-service-account-token --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: kiali-remote-access-cluster-role rules: - apiGroups: - \u0026#39;*\u0026#39; resources: - \u0026#39;*\u0026#39; verbs: - get - list - watch - nonResourceURLs: - \u0026#39;*\u0026#39; verbs: - get --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: kiali-remote-access-cluster-role-binding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: kiali-remote-access-cluster-role subjects: - kind: ServiceAccount name: kiali-remote-access-service-account namespace: istio-system --- apiVersion: v1 kind: Secret metadata: name: kiali-remote-access-service-account-token namespace: istio-system annotations: kubernetes.io/service-account.name: kiali-remote-access-service-account type: kubernetes.io/service-account-token Manifest 배포 1 kubectl apply -f Remote-Kiali-Account-Info.yml Remote ServiceAccount를 기반으로 kubeconfig 작성 ㅤ⚠️ Context : Remoteㅤ\nca.crt 값 확인 1 2 3 kubectl get secret kiali-remote-access-service-account-token \\ -n istio-system \\ -o jsonpath=\u0026#39;{.data.ca\\.crt}\u0026#39; token 값 확인 1 2 3 kubectl get secret kiali-remote-access-service-account-token \\ -n istio-system \\ -o jsonpath=\u0026#39;{.data.token}\u0026#39; | base64 -d KubeConfig 파일 작성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # Kiali-Remote-Kubeconfig.yml apiVersion: v1 kind: Config clusters: - name: workstation cluster: server: \u0026lt;Remote Cluster URL\u0026gt; certificate-authority-data: \u0026lt;위에서 추출한 ca.crt 값\u0026gt; users: - name: kiali-remote-access-service-account user: token: \u0026lt;위에서 추출한 token 값\u0026gt; contexts: - name: workstation-context context: cluster: workstation user: kiali-remote-access-service-account current-context: workstation-context Primary Kiali -\u0026gt; Remote Cluster에 접속 가능하도록 Secret 생성 ㅤ⚠️ Context : Primaryㅤ\nSecret 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 # Primary-Kiali-Multi-Cluster-Secret.yml apiVersion: v1 data: workstation: \u0026lt;위에서 생성한 Kiali-Remote-Kubeconfig.yml을 base64로 인코딩한 값\u0026gt; kind: Secret metadata: annotations: kiali.io/cluster: workstation labels: kiali.io/multiCluster: \u0026#39;true\u0026#39; name: monitoring-kiali-workstation-cluster-secret namespace: monitoring type: Opaque workstation은 사용 중인 Remote Cluster의 이름이다.\nSecret 배포 1 kubectl apply -f Primary-Kiali-Multi-Cluster-Secret.yml Primary에 Kiali 설치 ㅤ⚠️ Context : Primaryㅤ\nKiali 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Primary-Kiali.yml apiVersion: kiali.io/v1alpha1 kind: Kiali metadata: name: monitoring-kiali namespace: monitoring spec: auth: # ⚠️ 테스트를 위한 것으로 Production에서 사용할 때는 반드시 인증 절차를 구축하도록 하자 strategy: anonymous deployment: namespace: monitoring accessible_namespaces: - \u0026#39;*\u0026#39; external_services: prometheus: url: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090 Kiali 배포 1 kubectl apply -f Primary-Kiali.yml Primary Kiali 외부 진입점 설정 ㅤ⚠️ Context : Primaryㅤ\n이제 Kiali의 외부 진입점을 설정해 웹 페이지를 통해 확인해볼 차례이다.\nVirtualService 정의 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Primary-Kiali-VS.yml apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: monitoring-kiali-virtual-service namespace: monitoring spec: gateways: - \u0026lt;사용할 Istio Gateway. 가령, istio-system/my-istio-gateway\u0026gt; hosts: - \u0026lt;사용할 Domain Host. 가령, my-primary-kiali.example.com\u0026gt; http: - route: - destination: host: kiali port: number: 20001 VirtualService 배포 1 kubectl apply -f Primary-Kiali-VS.yml Kiali 페이지 접속 Ingress 혹은 VirtualService로 설정한 호스트에 접속해보자.\n두 클러스터의 워크로드들이 모두 표시된다면 성공이다. Istio Primary-Remote Multi Cluster Mesh 시나리오에 따라\nOKE(Primary)에 단일 제어 평면(Control Plane),\n그리고 각 클러스터에 1개씩의 데이터 평면(Data Plane)이 구성된 모습이다.\n마치며 이번 포스트에서는 Multi-Cluster 환경에서 모니터링 스택을 구축하는 방법을 다뤘다.\nPrimary Cluster에는 데이터를 수집하고 시각화하는 컴포넌트들(Grafana, Loki, Prometheus, Kiali)을 배포했고,\nRemote Cluster에는 데이터를 수집하여 Primary로 전송하는 컴포넌트들(Promtail, Prometheus)만 배포하여 리소스 사용을 최적화했다.\n이를 통해 여러 클러스터에 분산된 워크로드의 메트릭, 로그, 서비스 메시 정보를 하나의 대시보드에서 통합하여 확인할 수 있게 되었다.\n클러스터의 건강 상태를 지속적으로 모니터링하고 이상 징후를 조기에 발견하는 것은 안정적인 서비스 운영을 위한 필수 요소다.\n이번에 구축한 모니터링 스택이 기반이 되어 당신이 운영하는 클러스터의 건강검진을 수행하는 데 부디 도움이 되길 바란다.\n이번 포스트에서는 멀티 클러스터 모니터링 스택의 기본 구축에 집중했다.\n추가로 고려하면 좋을 주제들은 다음과 같다:\nAlertManager를 통한 알림 설정: 이상 징후 발생 시 즉시 알림을 받을 수 있도록 구성 리소스 튜닝: 클러스터 규모와 워크로드에 맞는 리소스 최적화 보안 설정: 인증/인가를 통한 접근 제어 및 데이터 보호 트러블슈팅 가이드: 자주 발생하는 문제와 해결 방법 네트워크 정책: 모니터링 컴포넌트 간 통신 보안 강화 백업 전략: 모니터링 데이터의 장기 보관 및 복구 계획 이러한 주제들은 추후 별도의 포스트를 통해 다루도록 하겠다.\n참고자료 Helm Charts loki-stack - Grafana Loki Stack Helm Chart kube-prometheus-stack - Prometheus Community Helm Charts kiali-operator - Kiali Operator Helm Chart 공식 문서 Prometheus 공식 문서 Grafana 공식 문서 Loki 공식 문서 Kiali 공식 문서 Istio 공식 문서 ","date":"2025-11-23T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/configuring-monitoring-stack-on-multiple-cluster/images/cover_hu_f54af0383fe8c5b9.png","permalink":"https://blog.ayteneve93.com/p/dev/configuring-monitoring-stack-on-multiple-cluster/","title":"Multi-Cluster 모니터링 스택 구축하기"},{"content":"멀티클러스터 메시 환경에 모니터링 스택을 얹으려고\nCDK for Terraform으로 Helm 차트를 Kubernetes에 배포하던 중이었다.\n그러다 돌연 500 Internal Server Error 발생했다.\n무슨 일인가 조사해봤는데, 아무래도 Cloudflare 자체에 문제가 생긴 모양이다.\nX(구 트위터)도 접속이 안 되고\nGPT 페이지도 에러를 뿜고\n당연히 내 블로그도 에러가 난다. 😢\nCloudflare Status 페이지를 보니 지금 열심히 복구 작업 중인 모양이다.\n디스코드 채널에도 들어가 봤는데 하나같이 불만을 성토하는 채팅이 잔뜩이다.\n서버가 죽었다고 시위하는 사람, 합법적으로 농땡이 부린다고 마냥 좋아하는 사람,\n무슨 일 있나 싶어 그냥 들렀다가 혼란에 휩쓸린 사람까지 그야말로 소돔과 고모라를 방불케 했다.\n이건 뭐, 즉각적인 해결책이랄 것도 없다. 그냥 Cloudflare 팀을 믿고 기다리는 수밖에.\n추후 비슷한 일을 대비해 CDN을 이중화해야 할까 고민만 잔뜩 쌓여 간다. (근데 그게 가능은 한가?)\n","date":"2025-11-18T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/etc/cloudflare-500-error/images/cover_hu_a30f7fcbf37c8e6d.png","permalink":"https://blog.ayteneve93.com/p/etc/cloudflare-500-error/","title":"Cloudflare 500 Internal Server Error"},{"content":"들어가기 앞서 지난 달 말에 \u0026ldquo;k8s에 Ollama AI 서버 올려보기\u0026rdquo;라는 포스트를 통해\n\u0026ldquo;GPU가 포함된 인프라 구축에 대해 고민 중\u0026ldquo;이라는 내용을 언급했다.\nGPU 서버 스펙 이리저리 정보를 취합해 비교/토의한 결과,\n결국 독립된 GPU 서버를 IDC 센터에 설치하는 형태로 마무리 되었다.\n그냥 GPU 서버를 클라우드에 올려 준다면야 나는 매우 편하고 좋겠지만,\n클라우드의 엄청난 비용 부담을 고려하면 이 방식이 가장 합리적인 판단이라고 생각한다.\nRTX A6000 GPU의 성능이 지금 당장은 충분해 보이나,\n향후 확장성을 고려하면 GPU 서버 역시 k8s 클러스터로 구성해야 한다.\n하지만 실제 서비스를 사용자에게 제공할 때는 클라우드에 있는 k8s를 사용한다.\nGPU 클러스터는 GPU 중심의 워크로드만 담당하고 각종 인증, 데이터 처리, 웹 서비스 등은 클라우드에서 제공한다는 의미이다.\n요컨대 클러스터 간의 통신을 구축할 필요성이 생겼다.\n사실 이런 시나리오를 전혀 예측하지 못한 것은 아니다.\n현대적인 인프라 아키텍처에서는 점차 여러 개의 k8s 클러스터를 운영하는 것이 일반적인 관습이 되어가고 있다. 처음에 k8s를 사용할 때는 하나의 클러스터로도 필요한 모든 서비스를 처리하는 게 가능해 보이지만, 확장성이나 장애 시 복원 탄력성 등 머지 않아 그 한계에 부딪히게 된다.\n이번 GPU 케이스의 경우 \u0026ldquo;클라우드에 올리기에 너무 비싸 자체적으로 운영한다\u0026quot;라는 물리적인 확장성 이슈이다.\nIstio는 이런 상황에 대응할 수 있도록 다수의 클러스터를 하나의 서비스 메시로 통합해 여러 클러스터에 걸친 워크로드 간 통신을 안전하고(mTLS, Authorization Policy), 일관된 방식(트래픽 제어)으로 구성할 수 있는 기능을 제공한다.\nService Mesh 확장 방식 Istio Deployment Models 공식 문서에 따르면 서비스 메시 확장은 크게 2가지 모델이 존재한다.\nMultiple Clusters\n동일한 Service Mesh가 여러 k8s 클러스터에 걸쳐 뻗어있는 방식이다. (클러스터 클러스터)\n여러 클러스터의 서비스들이 하나의 통합된 메시로 관리되며, 클러스터 간 자동 서비스 발견 및 로드 밸런싱이 가능하다.\nMultiple Clusters 모델의 주요 특징:\n서비스 발견 (Service Discovery): 여러 클러스터의 서비스 엔드포인트를 자동으로 통합 크로스 클러스터 로드 밸런싱: 요청을 여러 클러스터의 엔드포인트에 자동 분산 네트워크 구성: 단일 네트워크 또는 다중 네트워크(지리적 분산, 보안 격리) 구성 가능 제어 평면 (Control Plane): 단일 제어 평면으로 여러 클러스터 관리 혹은 각 클러스터에 독립적으로 배치 클러스터 간 엔드포인트 발견을 위해서는 각 제어 평면에 remote secret을 배포하여 다른 클러스터의 API 서버에 접근할 수 있도록 구성해야 한다.\n하나의 국가 내에 존재하는 여러 도시들을 생각해보자, 예를 들면 대한민국의 서울/부산/대구.\n지리적 위치는 물론 수도, 가스, 전기, 하다못해 지하철 노선 등등 분명 독립된 인프라가 존재한다.\n하지만 모든 도시는 \u0026ldquo;대한민국\u0026quot;이라는 국가 내에서 동일한 법률, 정부, 화폐, 신분증 체계를 공유한다.\n도시 간 이동은 상당히 자유로우며, 다른 도시에 있는 사람을 같은 국가의 시민으로 인식한다.\nMultiple Meshes\n여러 개의 독립적인 서비스 메시를 연합(Mesh Federation)하는 방식이다. (클러스터 클러스터 클러스터)\n각 메시는 고유한 mesh ID를 가지며, 서비스 이름이나 네임스페이스 이름을 재사용할 수 있다.\nMultiple Meshes 모델은 다음과 같은 상황에서 유용하다:\n조직 경계: 서로 다른 조직이나 사업부가 독립적인 메시를 운영 강한 격리: 테스트 워크로드와 프로덕션 워크로드 간 완전한 격리 메시 간 통신을 위해서는 Mesh Federation을 구성해야 하며, 서로 다른 trust domain을 가진 메시 간 통신을 위해서는 trust bundle 교환이 필요하다.\n세상에는 여러 개의 나라가 있다. 예를 들면 대한민국, 일본, 미국 등등.\n각각의 국가는 기본적인 인프라는 물론 저마다 다른 법률, 정부, 통화, 언어 및 신분증 체계를 가지고 있다.\n국가 간 이동은 여권과 비자가 필요하며, 행동에 상당한 제약이 따른다.\n가령, 미국에 여행 간 사람이 총기를 구매한다거나 투표를 한다거나 하는 것은 불가능하다.\n멀티 클러스터 서비스 메시 시나리오 위 언급한 2가지 모델 중 이번에 사용할 것은 멀티 클러스터이다.\n멀티 클러스터 메시를 구성할 때는 네트워크 분리 여부와 제어 평면 배포 방식에 따라 4개의 시나리오가 있다.\n시나리오 네트워크 제어 평면(Control Plane) 1. Multi-Primary 동일 네트워크 개별 배포 2. Primary-Remote 동일 네트워크 단일 배포 3. Multi-Primary on different network 개별 네트워크 개별 배포 4. Primary-Remote on different network 개별 네트워크 단일 배포 내 경우 연결할 두 클러스터의 네트워크가 물리적으로 완전히 분리된 상황이므로 3번 혹은 4번만 보면 된다.\nMulti-Primary와 Primary-Remote의 차이는 제어 평면을 각 클러스터마다 배포 할 것인지(Multi-Primary),\n아니면 하나의 제어 평면만 두고(Primary), 다른 클러스터를(Remote) 관리만 할 것인지(Primary-Remote)이다.\n앞서 비유를 이어가면 다음과 같다.\nMulti-Primary (개별 제어 평면): 연방제 국가의 각 주(州)가 독립적인 정부를(주 정부) 가지는 방식.\n예를 들어, 미국의 캘리포니아, 텍사스, 뉴욕은 각각 독립적인 주 정부를 가지고 있다.\n한 주의 정부가 마비되어도 다른 주는 정상적으로 운영되지만, 각 주마다 정부 조직을 유지해야 하므로 비용과 운영 부담이 크다.\nPrimary-Remote (단일 제어 평면): 단일 국가의 중앙 정부가 여러 지역을 관리하는 방식.\n예를 들어, 우리나라는 중앙정부(서울)가 서울, 부산, 대구 등 모든 도시를 관리한다.\n하나의 정부로 통합 관리하므로 운영이 간단하지만, 중앙정부가 마비되면 전국이 위험해진다.\n(물론 우리나라도 지방 자치가 있긴 한데, 미국이나 독일 등의 주 정부에 비하면 권한이 매우 약하다.)\n개별 제어 평면(Multi-Primary) 단일 제어 평면(Primary-Remote) 장점 고 가용성, 장애 격리 구성과 운영 난이도가 낮음 단점 리소스 소모가 많아지고 운영 난이도가 높음 Primary의 Istiod 다운 시 전체 메시가 위험해짐 2가지 시나리오를 모두 경험해보고 싶어서, 내가 개인적으로 운영하는 k8s는 단일 제어 평면,\n회사의 k8s는 개별 제어 평면으로 구성하려고 한다.\n이번 포스트에선 개인 k8s에 멀티 클러스터 서비스 메시를 Primary-Remote on different network 시나리오에 맞춰 구성한 내용을 담는다.\n멀티 클러스터 서비스 메시 구현 실습한 시나리오는 Primary-Remote이다.\nPrimary Cluster는 Oracle Cloud Infrastructure에 OKE Cluster,\nRemote Cluster는 집에 있는 On-Premise Cluster이다.\nPrimary 클러스터에 단일 제어 평면(Istio Control Plane)을 설치하고,\nRemote 클러스터는 Primary가 만든 서비스 메시에 클라이언트로서 참여한다.\n전체 클러스터를 아우르는 Mesh ID 값을 정해야 한다.\n내 경우 apex-captain-mesh로 지정했다.\n각 클러스터에 Istio 리소스가 배포되는 Namespace는 istio-system이다. (기본값)\n각 클러스터의 이름과 네트워크명을 할당했다.\nPrimary Cluster는 oke, Remote Cluster는 workstation이다.\n⚠️ 두 클러스터를 왔다갔다 하면서 작업해야 해서 헷갈릴 수 있다.\n각 섹션 앞에는 어느 Context에서 진행하는 내용인지 명시 해두었으니 참고하자.\nPrimary 기본 네트워크 설정 ㅤ⚠️ Context : Primaryㅤ\nPrimary 클러스터의 istio-system Namespace에 Network 값을 Label로 달아준다.\n1 kubectl label namespace istio-system topology.istio.io/network=oke OKE 클러스터를 Primary로 구성 ㅤ⚠️ Context : Primaryㅤ\n우선 istio-base를 설치한다.\n1 helm install istio-base istio/base -n istio-system istiod는 다음과 같은 values를 사용해 설치한다.\n1 2 3 4 5 6 7 8 # Primary-Istiod.yml global: meshID: apex-captain-mesh externalIstiod: true network: oke multiCluster: clusterName: oke # 기타 설정... 1 2 helm install istiod istio/istiod -n istio-system \\ -f Primary-Istiod.yml global.externalIstiod를 true로 설정하는 것이 중요하다.\n이는 OKE 클러스터에 설치되는 Istiod가 외부 제어 평면으로 기능 할 수 있도록 하는 플래그이다.\nPrimary에 east-west gateway 설치 ㅤ⚠️ Context : Primaryㅤ\nIstio에는 north-south, east-west 게이트웨이라는 개념이 있다.\n남북이니, 동서니 하는데 이게 Istio에서만 쓰는 이상한 용어는 아니고, 트래픽의 방향에 대한 얘기이다.\n위 그림의 Data Center를 각각 하나의 k8s라고 생각해 보자.\nExternal Network, 즉 \u0026ldquo;인터넷\u0026quot;을 북쪽, \u0026ldquo;k8s\u0026quot;를 남쪽에 두면\nnorth-south 트래픽이란 것은 인터넷과 서비스 간, 즉 외부 사용자와의 통신을 의미한다.\n일반적으로 많이 쓰는 \u0026ldquo;Nginx Ingress Controller\u0026quot;가 담당하는 것이 north-south 트래픽인 것이다.\n반면, 두 k8s를 나란히 옆으로 두면 두 k8s 간의 통신은\n동-서 간, 즉 east-west 간의 통신이 된다.\neast-west 게이트웨이는 k8s 간의 istio mesh 구성을 위해 통신하는 출입문이다.\n멀티 클러스터 서비스 메시를 구성하는 모든 k8s에 하나씩 필요하다.\n우선 Primary 클러스터에 east-west gateway를 설치해보자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Primary-Istio-East-West-Gateway.yml name: istio-eastwestgateway networkGateway: oke service: type: LoadBalancer # Reserved IP를 생성해서 고정된 값을 넣는 것을 추천한다 loadBalancerIP: \u0026lt;할당할 LB의 IP\u0026gt; # 기타 추가할 Annotations # 아래는 OCI Free Tier에 부합하는 로드밸런서 설정이다. # 사용하는 클라우드 혹은 LB 공급자에 맞춰 설정하자. # annotations: # \u0026#39;service.beta.kubernetes.io/oci-load-balancer-security-list-management-mode\u0026#39;:\u0026#39;None\u0026#39;, # \u0026#39;service.beta.kubernetes.io/oci-load-balancer-shape\u0026#39;:\u0026#39;flexible\u0026#39;, # \u0026#39;service.beta.kubernetes.io/oci-load-balancer-shape-flex-max\u0026#39;:\u0026#39;10\u0026#39;, # \u0026#39;service.beta.kubernetes.io/oci-load-balancer-shape-flex-min\u0026#39;:\u0026#39;10\u0026#39;, helm 차트 배포:\n1 2 3 helm install istio-eastwestgateway istio/gateway \\ -n istio-system \\ -f Primary-Istio-East-West-Gateway.yml 배포 상태 확인:\n1 kubectl get svc -n istio-system -l app=istio-eastwestgateway 다음과 같이 나오면 정상이다.\n1 2 NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE istio-eastwestgateway LoadBalancer \u0026lt;클러스터 내부 IP\u0026gt; \u0026lt;LB의 IP\u0026gt; 15021:30358/TCP,15443:30461/TCP,15012:30494/TCP,15017:32510/TCP 2d4h Primary Cluster의 제어 평면 노출 ㅤ⚠️ Context : Primaryㅤ\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 # Primary-Expose-Istiod.yml apiVersion: networking.istio.io/v1 kind: Gateway metadata: name: istiod-gateway spec: selector: istio: eastwestgateway servers: - port: name: tls-istiod number: 15012 protocol: tls tls: mode: PASSTHROUGH hosts: - \u0026#34;*\u0026#34; - port: name: tls-istiodwebhook number: 15017 protocol: tls tls: mode: PASSTHROUGH hosts: - \u0026#34;*\u0026#34; --- apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: istiod-vs spec: hosts: - \u0026#34;*\u0026#34; gateways: - istiod-gateway tls: - match: - port: 15012 sniHosts: - \u0026#34;*\u0026#34; route: - destination: host: istiod.istio-system.svc.cluster.local port: number: 15012 - match: - port: 15017 sniHosts: - \u0026#34;*\u0026#34; route: - destination: host: istiod.istio-system.svc.cluster.local port: number: 443 k8s manifest 배포:\n1 kubectl apply -f Primary-Expose-Istiod.yml -n istio-system 배포 상태 확인:\n1 kubectl get gateway,vs -n istio-system 다음과 같이 나오면 정상이다.\n1 2 3 4 5 NAME AGE gateway.networking.istio.io/istiod-gateway 2d4h NAME GATEWAYS HOSTS AGE virtualservice.networking.istio.io/istiod-vs [\u0026#34;istiod-gateway\u0026#34;] [\u0026#34;*\u0026#34;] 2d4h Remote Cluster 제어 평면 설정 ㅤ⚠️ Context : Remoteㅤ\nRemote 클러스터의 istio-system 네임스페이스에 Primary의 제어 평면을 사용할 것임을 지정해주자.\n1 2 kubectl annotate namespace istio-system \\ topology.istio.io/controlPlaneClusters=oke Remote 기본 네트워크 설정 ㅤ⚠️ Context : Remoteㅤ\nRemote 클러스터의 istio-system Namespace에도 Network 값을 Label로 달아준다.\n1 kubectl label namespace istio-system topology.istio.io/network=workstation Workstation 클러스터를 Remote로 구성 ㅤ⚠️ Context : Remoteㅤ\nRemote에도 istio-base를 설치한다.\n1 2 helm install istio-base istio/base -n istio-system \\ --set profile=remote profile 값을 \u0026ldquo;remote\u0026ldquo;로 해준다.\nistiod는 다음과 같은 values를 사용해 설치한다.\n1 2 3 4 5 6 7 8 9 10 11 12 # Remote-Istiod.yml profile: remote global: configCluster: true remotePilotAddress: \u0026lt;Primary 클러스터의 East-West Gateway LB IP\u0026gt; multiCluster: clusterName: workstation network: workstation istiodRemote: injectionPath: /inject/cluster/workstation/net/workstation # 기타 설정... 1 2 helm install istiod istio/istiod -n istio-system \\ -f Remote-Istiod.yml Remote 클러스터의 연결 정보 생성 ㅤ⚠️ Context : Remoteㅤ\nistioctl 명령어로 Remote Cluster에 Service Account와 Role/RoleBinding을 만들고\n이 정보를 Primary에 Secret으로 넣어줘야 한다.\n1 2 istioctl create-remote-secret \\ --name=workstation \u0026gt; remote-workstation.yml remote-workstation.yml 파일이 생성되었다면 성공이다.\nPrimary에 Remote 클러스터의 연결 정보 주입 ㅤ⚠️ Context : Primaryㅤ\n위에서 생성한 remote-workstation.yml 파일을 그대로 Primary 클러스터에 배포해준다.\n1 kubectl apply -f remote-workstation.yml Remote에 east-west gateway 설치 ㅤ⚠️ Context : Remoteㅤ\n1 2 3 4 5 6 7 8 9 10 11 12 # Remote-Istio-East-West-Gateway.yml name: istio-eastwestgateway networkGateway: workstation service: type: LoadBalancer loadBalancerIP: \u0026lt;할당할 LB의 IP\u0026gt; # 이 부분이 좀 특이한데, # 내가 운영 중인 Workstation Cluster의 경우 Iptime Router 뒤에서 동작한다. # 단순 LB IP만 가지고는 Primary가 접근할 수 없으므로 # 실제 Router의 IP를 넣어줘야 한다. externalIPs: - \u0026lt;Router의 외부 IP\u0026gt; helm 차트 배포:\n1 2 3 helm install istio-eastwestgateway istio/gateway \\ -n istio-system \\ -f Remote-Istio-East-West-Gateway.yml 배포 상태 확인:\n1 kubectl get svc -n istio-system -l app=istio-eastwestgateway 다음과 같이 나오면 정상이다.\n1 2 NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE istio-eastwestgateway LoadBalancer \u0026lt;클러스터 내부 IP\u0026gt; \u0026lt;LB의 IP\u0026gt;,\u0026lt;Router의 IP\u0026gt; 15021:30358/TCP,15443:30461/TCP,15012:30494/TCP,15017:32510/TCP 35h Primary / Remote 양쪽 모두에 Cross Network Gateway 배포 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Cross-Network-Gateway.yml apiVersion: networking.istio.io/v1 kind: Gateway metadata: name: cross-network-gateway spec: selector: istio: eastwestgateway servers: - port: number: 15443 name: tls protocol: TLS tls: mode: AUTO_PASSTHROUGH hosts: - \u0026#34;*.local\u0026#34; Cross-Network-Gateway.yml을 양쪽 클러스터 모두에 배포해준다.\nㅤ⚠️ Context : Primaryㅤ\n1 kubectl apply -f Cross-Network-Gateway.yml -n istio-system ㅤ⚠️ Context : Remoteㅤ\n1 kubectl apply -f Cross-Network-Gateway.yml -n istio-system 기본 연결 상태 확인 istioctl로 멀티 클러스터 상태 확인 ㅤ⚠️ Context : Primaryㅤ\n1 istioctl remote-clusters 다음과 같이 나오면 정상이다.\n1 2 3 NAME SECRET STATUS ISTIOD oke synced istiod-59fc7749cd-2bvtx workstation istio-system/istio-remote-secret-workstation synced istiod-59fc7749cd-2bvtx Primary(oke)에 Secret이 안 보이는 건 당연하다.\nOKE 자체가 istio 입장에선 Home이라 그렇다. 모든 클러스터가 synced이면 정상이다.\nistioctl로 프록시 상태 확인 ㅤ⚠️ Context : Primaryㅤ\n1 istioctl proxy-status 다음과 같이 나오면 정상이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 NAME CLUSTER ISTIOD VERSION SUBSCRIBED TYPES ak-outpost-oke-authentik-proxy-outpost-57bd89b88c-9968m.authentik oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) ak-outpost-workstation-authentik-proxy-outpost-fcfb6fd5b-5hs99.authentik workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) authentik-postgresql-0.authentik oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) authentik-redis-master-0.authentik oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) authentik-server-7bc95fb9b9-qgnsp.authentik oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) authentik-worker-586897c75d-vspq7.authentik oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) cloudbeaver-deployment-5dcf4cd996-nwmjs.cloudbeaver oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) game-sdtd-deployment-f9dd8578b-llhks.game workstation istiod-59fc7749cd-2bvtx 1.28.0 4 (CDS,LDS,EDS,RDS) game-sftp-deployment-75ddb69cb4-pmbx7.game workstation istiod-59fc7749cd-2bvtx 1.28.0 4 (CDS,LDS,EDS,RDS) home-l2tp-vpn-proxy-stateful-set-1.home-l2tp-vpn-proxy oke istiod-59fc7749cd-2bvtx 1.28.0 4 (CDS,LDS,EDS,RDS) ingress-controller-ingress-nginx-controller-85cf749c6f-xzh4d.ingress-controller workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) istio-eastwestgateway-6cfbb6f579-j8rgd.istio-system oke istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,SDS) istio-eastwestgateway-787568f5ff-w5jr5.istio-system workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,SDS) jellyfin-859d64757b-qrh4m.nas workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) nas-sftp-deployment-6544d44588-5kvng.nas workstation istiod-59fc7749cd-2bvtx 1.28.0 4 (CDS,LDS,EDS,RDS) ollama-75d4d849bc-xzlfz.ollama workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) open-webui-0.open-webui workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) open-webui-pipelines-996f759d7-gzxwg.open-webui workstation istiod-59fc7749cd-2bvtx 1.28.0 5 (CDS,LDS,EDS,RDS,NDS) open-webui-tika-fb987c668-8zswp.open-webui workstation istiod-59fc7749cd-2bvtx 1.28.0 4 (CDS,LDS,EDS,RDS) redis-ui-deployment-65b968c54f-mbxpc.redis-ui oke istiod-59fc7749cd-2bvtx 1.28.0 4 (CDS,LDS,EDS,RDS) 크로스 클러스터 트래픽 테스트 Istio 공식 가이드에 따라 두 클러스터에 샘플 서비스를 배포해 정상적으로 동작하는지 테스트해보자.\nPrimary에 샘플 앱 배포 ㅤ⚠️ Context : Primaryㅤ\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 # Primary-Sample.yml apiVersion: v1 kind: Namespace metadata: name: sample labels: istio-injection: enabled --- apiVersion: v1 kind: Service metadata: namespace: sample name: helloworld labels: app: helloworld service: helloworld spec: ports: - port: 5000 name: http selector: app: helloworld --- apiVersion: apps/v1 kind: Deployment metadata: namespace: sample name: helloworld-v1 labels: app: helloworld version: v1 spec: replicas: 1 selector: matchLabels: app: helloworld version: v1 template: metadata: labels: app: helloworld version: v1 spec: containers: - name: helloworld image: docker.io/istio/examples-helloworld-v1:1.0 resources: requests: cpu: \u0026#34;100m\u0026#34; imagePullPolicy: IfNotPresent #Always ports: - containerPort: 5000 --- apiVersion: v1 kind: ServiceAccount metadata: namespace: sample name: curl --- apiVersion: v1 kind: Service metadata: namespace: sample name: curl labels: app: curl service: curl spec: ports: - port: 80 name: http selector: app: curl --- apiVersion: apps/v1 kind: Deployment metadata: namespace: sample name: curl spec: replicas: 1 selector: matchLabels: app: curl template: metadata: labels: app: curl spec: terminationGracePeriodSeconds: 0 serviceAccountName: curl containers: - name: curl image: docker.io/curlimages/curl:8.16.0 command: [\u0026#34;/bin/sleep\u0026#34;, \u0026#34;infinity\u0026#34;] imagePullPolicy: IfNotPresent volumeMounts: - mountPath: /etc/curl/tls name: secret-volume volumes: - name: secret-volume secret: secretName: curl-secret optional: true --- k8s manifest 배포:\n1 kubectl apply -f Primary-Sample.yml Remote에 샘플 앱 배포 ㅤ⚠️ Context : Remoteㅤ\nPrimary와 거의 유사하다. Deployment만 조금 다르다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 # Remote-Sample.yml apiVersion: v1 kind: Namespace metadata: name: sample labels: istio-injection: enabled --- apiVersion: v1 kind: Service metadata: namespace: sample name: helloworld labels: app: helloworld service: helloworld spec: ports: - port: 5000 name: http selector: app: helloworld --- apiVersion: apps/v1 kind: Deployment metadata: namespace: sample name: helloworld-v2 labels: app: helloworld version: v2 spec: replicas: 1 selector: matchLabels: app: helloworld version: v2 template: metadata: labels: app: helloworld version: v2 spec: containers: - name: helloworld image: docker.io/istio/examples-helloworld-v2:1.0 resources: requests: cpu: \u0026#34;100m\u0026#34; imagePullPolicy: IfNotPresent #Always ports: - containerPort: 5000 --- apiVersion: v1 kind: ServiceAccount metadata: namespace: sample name: curl --- apiVersion: v1 kind: Service metadata: namespace: sample name: curl labels: app: curl service: curl spec: ports: - port: 80 name: http selector: app: curl --- apiVersion: apps/v1 kind: Deployment metadata: namespace: sample name: curl spec: replicas: 1 selector: matchLabels: app: curl template: metadata: labels: app: curl spec: terminationGracePeriodSeconds: 0 serviceAccountName: curl containers: - name: curl image: docker.io/curlimages/curl:8.16.0 command: [\u0026#34;/bin/sleep\u0026#34;, \u0026#34;infinity\u0026#34;] imagePullPolicy: IfNotPresent volumeMounts: - mountPath: /etc/curl/tls name: secret-volume volumes: - name: secret-volume secret: secretName: curl-secret optional: true --- k8s manifest 배포:\n1 kubectl apply -f Remote-Sample.yml Primary에서 연결 테스트 ㅤ⚠️ Context : Primaryㅤ\n1 2 3 4 kubectl exec -n sample -c curl \\ \u0026#34;$(kubectl get pod -n sample -l \\ app=curl -o jsonpath=\u0026#39;{.items[0].metadata.name}\u0026#39;)\u0026#34; \\ -- curl -sS helloworld.sample:5000/hello 위 명령어를 여러 번 반복해보자.\n다음과 같이 v1과 v2가 출력되면 정상이다.\n1 2 3 4 Hello version: v2, instance: helloworld-v2-6746879bdd-8csng Hello version: v1, instance: helloworld-v1-5787f49bd8-kmgdm Hello version: v1, instance: helloworld-v1-5787f49bd8-kmgdm ... Remote에서 연결 테스트 ㅤ⚠️ Context : Remoteㅤ\n1 2 3 4 kubectl exec -n sample -c curl \\ \u0026#34;$(kubectl get pod -n sample -l \\ app=curl -o jsonpath=\u0026#39;{.items[0].metadata.name}\u0026#39;)\u0026#34; \\ -- curl -sS helloworld.sample:5000/hello 위 명령어를 여러 번 반복해보자.\n다음과 같이 v1과 v2가 출력되면 정상이다.\n1 2 3 4 Hello version: v1, instance: helloworld-v1-5787f49bd8-kmgdm Hello version: v1, instance: helloworld-v1-5787f49bd8-kmgdm Hello version: v2, instance: helloworld-v2-6746879bdd-8csng ... 여기까지 결과를 봤다면 멀티 클러스터 서비스 메시 구성은 끝이다. 🎉\n두 클러스터를 왔다갔다 하며 설정해야 하기에 헷갈릴 수 있는데,\n공식 문서의 시나리오를 천천히 따라가다 보면 어렵지 않게 설정할 수 있을 것이다.\n확인이 끝났다면 각 클러스터에서 sample 네임스페이스는 지워 주도록 하자.\n1 kubectl delete ns sample 당면했던 문제들 멀티 클러스터 메시 구성 자체는 그렇게 어렵지는 않았다.\n다만 메시를 구성하면서 온갖 종류의 문제에 직면 했었는데,\n어떤 일이 있었고 어떤식으로 대응했는지 간단하게 서술하겠다.\nIngress 로드밸런서는 공짜가 아니다\n클라우드에서 제공하는 LoadBalancer는 당연히 비용이 발생한다.\nOCI의 Flexible LB는 1개까지는 무료이고, 초과분부터는 1달에 개당 $8.41이 부과된다.\n환율 1,450원으로 계산하면 12,000원 정도이다.\n그렇게 비싸진 않지만\n어쨌든 LB가 늘어나면 비용이 발생한다.\nNginx Ingress Controller와 같은 LB를 공유할 수 없다\nNginx Ingress Controller로 Ingress 처리를 하고 있었는데,\nIstio East-West Gateway를 배포하려면 별도의 LB를 따로 구해야 한다.\n메시 구성을 위해 달 12,000원이 부과된다는 것이다.\n해결책:\nIstio East-West Gateway가 Istio Ingress Gateway의 기능을 겸하도록 하고,\nNginx Ingress Controller를 통째로 제거했다.\nIngress 처리 방식 자체를 Istio VirtualService 방식으로 마이그레이션 한 것이다.\n여기에 대해선 할 얘기가 많지만 본 주제에서 벗어나므로 추후 포스팅 하도록 하겠다.\n다만 Helm으로 East-West Gateway 배포 시 별도 포트 설정이 안 돼서 엄청 애를 먹었었는데,\n이를 해결하기 위해 CDK for Terraform으로 별도 Execution 스크립트를 작성해서 강제 할당하였다.\nGithub 저장소에 올려 두었다. 누군가에게는 도움이 되지 않을까 싶다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 istioEastWestGatewayServicePortPatch = this.provide( Resource, \u0026#39;istioEastWestGatewayServicePortPatch\u0026#39;, () =\u0026gt; { const kubeConfigPath = this.k8sOkeEndpointStack.okeEndpointSource.shared.kubeConfigFilePath; const proxyUrl = this.k8sOkeEndpointStack.okeEndpointSource.shared.proxyUrl.socks5; const serviceName = this.istioEastWestGatewayRelease.shared.name; const namespace = this.namespace.element.metadata.name; const additionalPorts: { port: number; name: string; targetPort: number; protocol: string; present: boolean; }[] = [ { port: 80, name: \u0026#39;http\u0026#39;, targetPort: 80, protocol: \u0026#39;TCP\u0026#39;, present: true, }, { port: 443, name: \u0026#39;https\u0026#39;, targetPort: 443, protocol: \u0026#39;TCP\u0026#39;, present: true, }, ]; const provisioners = additionalPorts.map\u0026lt;LocalExecProvisioner\u0026gt;( ({ port, name, targetPort, protocol, present }) =\u0026gt; { if (present) { // 포트 추가 return { type: \u0026#39;local-exec\u0026#39;, command: dedent` port_exists=$(kubectl get svc ${serviceName} -n ${namespace} \\ -o jsonpath=\u0026#39;{.spec.ports[?(@.port==${port})].port}\u0026#39; 2\u0026gt;/dev/null || echo \u0026#39;\u0026#39;) if [ -z \u0026#34;$port_exists\u0026#34; ]; then kubectl patch svc ${serviceName} -n ${namespace} \\ --type=\u0026#39;json\u0026#39; \\ -p=\u0026#39;[{\u0026#34;op\u0026#34;: \u0026#34;add\u0026#34;, \u0026#34;path\u0026#34;: \u0026#34;/spec/ports/-\u0026#34;, \u0026#34;value\u0026#34;: {\u0026#34;name\u0026#34;: \u0026#34;${name}\u0026#34;, \u0026#34;port\u0026#34;: ${port}, \u0026#34;protocol\u0026#34;: \u0026#34;${protocol}\u0026#34;, \u0026#34;targetPort\u0026#34;: ${targetPort}}}]\u0026#39; || true fi `, environment: { KUBECONFIG: kubeConfigPath, HTTPS_PROXY: proxyUrl, }, }; } else { return { type: \u0026#39;local-exec\u0026#39;, command: dedent` port_exists=$(kubectl get svc ${serviceName} -n ${namespace} \\ -o jsonpath=\u0026#34;{.spec.ports[?(@.port==${port})].port}\u0026#34; 2\u0026gt;/dev/null || echo \u0026#39;\u0026#39;) if [ -z \u0026#34;$port_exists\u0026#34; ]; then exit 0 fi ports_list=$(kubectl get svc ${serviceName} -n ${namespace} \\ -o jsonpath=\u0026#39;{.spec.ports[*].port}\u0026#39; 2\u0026gt;/dev/null || echo \u0026#39;\u0026#39;) idx=0 for port_item in $ports_list; do if [ \u0026#34;$port_item\u0026#34; = \u0026#34;${port}\u0026#34; ]; then kubectl patch svc ${serviceName} -n ${namespace} \\ --type=\u0026#39;json\u0026#39; \\ -p=\u0026#34;[{\\\u0026#34;op\\\u0026#34;: \\\u0026#34;remove\\\u0026#34;, \\\u0026#34;path\\\u0026#34;: \\\u0026#34;/spec/ports/$idx\\\u0026#34;}]\u0026#34; || exit 1 exit 0 fi idx=$((idx + 1)) done exit 1 `, environment: { KUBECONFIG: kubeConfigPath, HTTPS_PROXY: proxyUrl, }, }; } }, ); return { triggers: { releaseId: this.istioEastWestGatewayRelease.element.id, ports: JSON.stringify(additionalPorts), }, dependsOn: [this.istioEastWestGatewayRelease.element], provisioners, }; }, ); OAuth2 Proxy 위에서 이어지는 문제이다.\n기존 Nginx Ingress를 썼을 때는 GitHub OAuth2 앱을 기반으로 OAuth2 Proxy를\nNginx Auth 레이어에 추가해 보호 했었는데, Istio Gateway + VirtualService에서는\nGitHub OAuth를 사용하는 것이 불가능했다.\n엄밀히 말하면 가능은 한데, 여러 도메인을 동시에 보호하는 게 너무 힘들었다.\n어차피 언젠가 옮길 생각도 있었어서, 이참에 별도의 인증 앱을 배포하기로 했다.\n본래 Keycloak을 쓸 생각이었으나,\nKeycloak Helm Chart에도 적혀 있길, Bitnami가 2025년 8월 28일부로\n기존 무료 컨테이너 이미지 대부분을 레거시 저장소로 옮기고 업데이트를 중단했다. (\u0026hellip;)\n이미지가 모두 내려가 있다 여기에 대해선 이리저리 말이 많은데,\nBitnami가 Broadcom에 인수된 이후 Keycloak을 유료로 바꾸려는 게 아닌가 하는 의견이 지배적이다.\n해결책:\n다행히 Keycloak을 사용 중이던 것은 아니어서 그냥 대체 앱을 설치해서 쓰고 있다.\n선택한 앱은 Authentik. 이거에 대해서도 추후 포스팅 하도록 하겠다.\n다음은 실제 시연 영상이다.\nAuthentik으로 인증 거쳐서 Torrent 서비스에 접속 마치며 이번 작업을 통해 클라우드와 온프레미스 환경에 있는\n두 개의 Kubernetes 클러스터를 하나의 통합된 서비스 메시로 연결할 수 있게 되었다.\n멀티 클러스터 서비스 메시 구성 자체는 공식 문서를 따라하면 그렇게 어렵지 않았지만,\n실제 운영 환경에서 마주한 문제들 — 특히 Ingress와 인증 레이어의 재구성 — 은 예상보다 많은 시간을 소모했다.\n다만 이러한 과정을 통해 Istio의 Gateway/VirtualService 아키텍처에 대해 더 깊이 이해할 수 있었고, 결국 Nginx Ingress Controller에서 Istio로의 마이그레이션까지 완료할 수 있었다.\n앞서 언급했듯이, 다음 단계로는 회사에서 운영 중인 Kubernetes 클러스터에 Multi-Primary 모델을 적용해볼 계획이다. 이렇게 하면 더 높은 가용성과 장애 격리를 확보할 수 있을 것이다.\n또한 이번에 도입한 Authentik과 Istio Gateway 기반의 인증/라우팅 구조에 대해서도 별도의 포스팅으로 정리할 예정이다. 이 부분은 멀티 클러스터 구성보다는 일반적인 Istio 사용 사례에 가깝지만, 실제 운영 환경에서 겪은 경험들을 공유하면 누군가에게는 도움이 되지 않을까 싶다.\n현대적인 마이크로서비스 아키텍처에서는 단일 클러스터의 한계를 넘어 여러 클러스터를 운영하는 것이 점차 표준이 되어가고 있다. Istio는 이러한 트렌드에 부합하는 강력한 도구이며, 잘 활용한다면 복잡한 인프라를 일관된 방식으로 관리할 수 있다.\n혹시 이 글을 읽는 누군가가 비슷한 작업을 계획하고 있다면,\n이 글이 조금이나마 도움이 되기를 바란다.\n참고 자료\nIstio Deployment Models Istio Multiple Clusters Istio Multiple Meshes Istio Multi-Primary 설치 가이드 Istio Primary-Remote 설치 가이드 Istio Multi-Primary on different network Istio Primary-Remote on different network Istio 멀티클러스터 검증 가이드 ","date":"2025-11-15T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/istio-multi-cluster-service-mesh/images/cover_hu_dee18d189fb1aefa.png","permalink":"https://blog.ayteneve93.com/p/dev/istio-multi-cluster-service-mesh/","title":"Istio를 활용해 다중 Cluster에 Service Mesh 구성하기"},{"content":"들어가기 앞서 얼마 전에 \u0026ldquo;k8s에 Ollama AI 서버 올려보기\u0026rdquo;라는 포스트를 통해\n\u0026ldquo;GPU가 너무 비싸 인프라 구축을 어떻게 해야 할지 고민이다\u0026ldquo;라는 내용을 토로했다.\n얼핏 GPU 얘기에만 매몰된 것처럼 보였을 수도 있으나,\n당연하게도 클라우드 비용은 GPU만의 문제가 아니다.\n클라우드에는 컴퓨트, 스토리지, 네트워크 등 다양한 카테고리의\n서비스가 존재하고, 상황에 따라 비용을 최적화할 수 있는 방법들도 제각각이다.\n이번 포스트에서는 AWS를 중심으로 클라우드 비용 절약 팁을 공유하고자 한다.\n왜 AWS? 🤔\nGCP, Azure, OCI 등 다양한 클라우드가 있지만, 전부 다 다루기엔 한계가 있다.\n다행히 대부분 기본 개념은 유사하므로 이 포스트에서는 공통된 원칙을 위주로 기술한다.\n문제의 시작 On-Premise 서버 구축의 어려움 서버를 구축하는 일은 힘들다.\nCPU, 메모리, 용도에 맞는 각종 스토리지, PSU(파워 서플라이), 메인보드, 가끔 GPU같은 PCI-E 확장 카드.\n구매해야 하는 게 한두 가지가 아니다.\n컴퓨터를 구매한다고 끝나는 것도 아니다.\n정전을 대비한 외부 UPS, 끊김 걱정 없는 튼튼한 회선 역시 필요하다.\n그 외에도 OS, RAID 구성, 각종 네트워크 / 방화벽 설정 등등\u0026hellip;\n그런데 이런 일은 보통 IDC (Internet Data Center)에서 알아서 다 해주는 거 아닌가?\n결코 그렇지 않다 막상 해보면 서버 도입부터 실제 센터에 설치/설정하는 일련의 과정 동안\n하루에도 수십통씩 이메일과 전화, SMS 등을 보내고 받게 될 것이다. 나도 알고싶지 않았다.\n그렇다고 IDC 직원이 뭔가를 잘못 했다는 건 아니다.\n꼼꼼하게 하나하나 확인하면서 클라이언트에게 확인 받는 건 시스템 엔지니어로서 당연하다.\n클라우드 서비스를 사용하는 이유 바로 위 섹션의 문제점 극복을 포함해서 기업들이 클라우드를 사용하는 이유는 여러 가지가 있다.\n예를 들어:\n유연성과 확장성\nIDC에 데이터 서버를 구축한다고 생각해보자.\n위 제품은 Seagate IronWolf Pro 7200rpm, 512MB 캐시 모델이다.\n용량은 30TB, 가격은 작성일 기준 120만원 정도이다.\n서버에 필요한 디스크 크기가 아직 미정인 상황이다.\n디스크를 구매해야 하는데 어떤걸 사야할까?\n바로 제일 크고 아름다운 30TB HDD를 구매 더 작은 디스크를 우선 구매 -\u0026gt; 추후 점진적으로 늘리기 후자를 택하는 게 합리적이긴 하나,\n이러면 나중에 HDD를 장착할 물리적인 공간이나 포트가 부족해질 수도 있다.\n그렇담 클라우드는?\n클라우드 서비스를 통해 구축한다면 이러한 고민에서 해방된다.\n물리적 제약에 신경 쓸 필요 없이 지금 필요한 만큼의 서버를 구성 할 수 있고\n원하면 언제든, 원하는 만큼 스펙을 조절할 수 있다.\n보안\n민감한 정보를 안전하게 보호하는 것은 비즈니스를 성공적으로 운영하기 위한 기본적인 요소이다.\n간단하게는 보안그룹 인바운드/아웃바운드 규칙부터, (단일 서버 기준으로 하면 firewall에 해당)\nIAM을 통한 단일화된 그룹 / 사용자 관리, KMS와 같은 별도 Key 관리 서비스 등 클라우드의\n고급 보안 조치를 통해 보다 쉽게 관리하고 안심하고 사용할 수 있다.\nIaC를 통한 신속한 배포\nTerraform, Pulumi, CloudFormation 등 클라우드 서비스에 대한\n인프라를 코드로 관리할 수 있는 도구들을 사용하면 새로운 환경을 빠르게 구축할 수 있다.\n또한, 그 자체로 일련의 정리된 문서의 역할도 겸하므로 현재 어떤 서비스가 어떻게 구성되어 있는지,\n혹은 Git과 같은 버전 관리 도구로 인프라의 변경 이력을 추적하는 것도 용이하다.\nOn-Premise 환경에서도 OpenStack을 통해 비슷한 작업이 가능하지만,\n이는 엄연히 이야기하면 그 자체로 IaC 도구는 아니고, OS 혹은 플랫폼에 가까운 물건이다.\n그래 그럼 클라우드 서비스가 최고다 이건가?\n클라우드 서비스를 쓰면 생기는 문제 그럴 리가 있나. 세상만사 명이 있으면 암도 있는 법이다.\n이번에도 몇 가지 예시를 들어보면 다음과 같다.\n의존성\n특정 클라우드의 생태계에 지나치게 의존하다보면 그 자체로 기업의 비즈니스 운영에 영향을 줄 수 있다.\n예를 들어 AWS의 ECS를 사용해 컨테이너 클러스터를 구성했다고 가정해보자.\n심지어 EC2가 아닌 Fargate를 컴퓨팅 엔진으로 사용했고 ALB로 고유한 라우팅 규칙,\nCloudWatch를 사용한 모니터링 등등이 포함된 그야말로 AWS 환경에 특화된 클러스터를 만들어 냈다.\n그런데 어느 날 당신의 상사가 \u0026ldquo;AWS 이거 너무 비싸\u0026ldquo;라며 자체적인 k8s 클러스터를 만들거나 아니면 다른 클라우드로의 마이그레이션을 지시했다.\n대체 이 토폴로지를 어떻게 k8s 방식에 맞춰 이전해야 하는가?\n사실상 처음부터 다시 만드는 게 현명하다.\n기술적 허들\n클라우드 서비스를 통해 인프라 환경을 설정하고 운영하는 것은 굉장히 난해한 일이다.\n\u0026ldquo;클라우드 그거 마우스 딸깍 딸깍 아니냐?\u0026rdquo; 라는 비아냥도 몇 번 들은 적 있는데,\n이게 보기보다 굉장히 방대하고 복잡한 기술/지식이 필요한 분야이다.\n더욱이 클라우드마다 장단점도 있어서 2~3개의 클라우드에 걸쳐 인프라를 구축하는 경우도 많다.\n요컨대, 해당 분야에 능통한 전문 인력을 고용해야 한다는 것이다.\nOn-Premise에 서버를 구축/운영할 때는 적어도 전문가의 서포트를 받을 수 있다.\n24시간 언제나 상주하는 직원들이 사용자가 멍멍이 떡처럼 질문을 해도, 찰떡처럼 알아채고 문제 해결을 도와준다.\n하지만 클라우드 서비스에서 그런 수준의 친절과 수고를 기대하긴 어렵다.\n(물론 CSP가 대시보드 페이지만 띄워놓고 사용자에게 서버 임대료만 따박 따박 받아내는 자동사냥 시스템을 만들었단 소린 아니다.)\n비용\n바로 위 기술적 허들에서 이어지는 문제이자 이번 포스트의 주제이다.\n아이러니하게도 \u0026ldquo;비용\u0026quot;이라는 항목은 클라우드 서비스의 장점으로 더 많이 언급된다.\n구축하려는 시스템의 성격에 맞춰 필요한 서비스와 자원을 정확하게 선택하면\n효율성을 극대화할 수 있고, 결과적으로 비용을 최적화할 수 있다는 논리이다.\n그런데 문제는 \u0026ldquo;그걸 대체 어떻게 하냐?\u0026ldquo;이다.\n실제로 클라우드 비용 관리를 해보면 예상치 못한 지출이 발생하기 쉽다.\n사용한 만큼만 지불한다는 Pay-as-you-go 모델의 양면성인 셈이다.\n리소스를 제대로 관리하지 않으면 사용하지 않는 인스턴스가 계속 돌아가고,\n네트워크 트래픽이나 스토리지 I/O 같은 숨겨진 비용까지 합쳐져서\n결국 고정 비용이 거의 없는 IDC 대비 오히려 더 많은 비용이 나올 수도 있다.\n그래서 클라우드를 효과적으로 사용하려면 체계적인 비용 최적화 전략이 필요하다.\n공통사항 (클라우드) 리소스 태깅 AWS에는 \u0026ldquo;비용 할당 태그\u0026rdquo;를 통해 조직의 태그 할당 정책에 따라\nCost Explorer로 비용 분석/추적이 가능하다. 일단 어디에 얼마를 쓰는지 알아야 절약을 할 수 있다.\n개인 가계부에 비유하자면 사용한 돈에 \u0026ldquo;범주\u0026quot;를 붙이는 것과 같다.\n가령 여자/남자 친구와 주말에 롯데월드에 가서 놀았다면,\n이때 사용한 비용들에는 다음과 같은 태그를 붙일 수 있다.\n키 값 비고 Purpose Personal-Date 무엇을 위한 지출인가? Partner Girlfriend 혹은 Boyfriend 누구에게 할당된 비용인가? Location LotteWorld 어디서 발생한 비용인가? 클라우드 리소스도 마찬가지다.\n예를 들어 프로젝트 A의 개발팀 A가 사용한 EC2 인스턴스에는 다음과 같은 태그를 붙일 수 있다.\n키 값 비고 Project Project-A 어떤 프로젝트의 비용인가? Team Dev-Team-A 어느 팀이 사용하는가? Environment Development 어떤 환경인가? (Dev/Staging/Prod) 다만, AWS의 태그 값은 기본적으로 원자값만 사용할 수 있다.\nCSV 형태, 예를 들어 \u0026quot;Managers\u0026quot; : \u0026quot;Aron,Ted,James\u0026quot; 이렇게는 쓸 수 없다.\n정확히는 사용 자체는 가능하나, AWS 네이티브 기능의 한계로 이런 태그의 필터링이 불가능하다.\n반드시 그렇게 사용해야 하는 경우 DataDog과 같은 외부 서비스를 알아보도록 하자.\n전사적 지원 클라우드 비용 절약은 단순히 담당 엔지니어 1명, 혹은 DevOps팀만의 노력으로 달성하는 데 한계가 있다.\n아마 대부분 회사에서 프로젝트/일정 관리, 커뮤니케이션의 역할 등을 하는 협업 툴을 사용할 것이다.\n이를 통해 관련된 모든 인원들에게 클라우드 비용에 대한 지속적인 관심을 환기시키고 소통을 통해 해결해 나가고자 하는 노력이 필요하다.\n(애플리케이션) 리소스 모니터링 한 명의 개발자가 인프라도 관리하면서 거기에 올라가는 모든 앱을 전부 다 만드는 경우는 없을 것이다.\n애플리케이션을 빌드하는 건 결국 다른 팀, 다른 개발자일 확률이 높은데, 문제는 인프라에 올라간 리소스를\n얼마나 잘 활용하는가는 그들의 손에 달려있다는 것이다.\n조금 극단적인 예시를 하나 들어보겠다.\n개발팀 A가 구축한 프로젝트 A가 있다.\n이 프로젝트에는 RDS에 쿼리를 날려 데이터를 조회하고 분석한 뒤 다시 다른 테이블에 저장하는 프로세스가 있다. 만일 조회/분석해야 하는 데이터 튜플의 수가 1,000만개인데 별 생각 없이 한꺼번에 가져와서 작업하도록 만들었다면 어떻게 될까?\npod나 container에 리소스 제한을 걸어뒀다면 해당 서비스가 종료될 것이고, 그렇지 않았다면 물리적 한계까지 자원을 소모하게 될 것이다.\n해당 로직을 만들어 사단을 낸 직원을 추적/섬멸하든, 조용히 타이르든 그것은 회사 내규에 따라 다르겠으나, 굳이 이런 극단적인 예시가 아니어도 크고 작은 메모리 누수는 제법 빈번하다.\nPrometheus, Grafana등을 활용해 리소스를 모니터링 하고 사전에 알람을 보내게 구축하도록 하자.\n컴퓨팅 AWS는 EC2(Elastic Computing Cloud)가 이 파트에 해당한다.\n무슨 VM, Core Instance 등등 클라우드마다 부르는 방식은 다양한데 실제 앱이 동작하는 서버 컴퓨터를 대여해주는 서비스이다.\n특별한 경우가 아니라면 클라우드 비용의 거진 대부분은 여기서 발생한다.\nArm CPU EC2 구성 요소 중 GPU를 제외하면 가장 많은 비용을 차지하는 것이 바로 CPU이다.\nCPU 아키텍처는 보통 AMD64(혹은 x86_64)와 Arm64(혹은 aarch64)가 가장 많이 사용된다.\n이런 일반적으로 생각하는 PC나 서버에 들어가던 것이 AMD(x86)이고\n이런 스마트폰이나 태블릿같은 모바일 장치에 들어가던 것이 Arm이다.\n초고사양 모바일 게임을 즐기는 유저가 아니라면 스마트폰에 쿨링 팬을 다는 경우는 없을 것이다.\nArm 계열의 CPU는 AMD(x86)에 비해 저전력, 저발열, 고효율이라는 특징을 가진다.\n본래 서버용 CPU는 AMD(x86) 계열을 사용하는 것이 일반적이었는데,\n내 기억이 맞다면 애플의 M1 칩을 탑재한 맥북을 공개한 이후로 상황이 많이 바뀌었다.\n요즘은 Arm CPU를 사용하는 서버 인스턴스를 어느 클라우드에서나 기본적으로 제공한다.\n위 사진에서 vCPU 4, 메모리 8GiB로 통일하고 시간당 On-Demand 요금을 보면,\nAMD(x86)의 경우 시간당 $ 0.281, Arm은 시간당 $ 0.195이다.\nCPU 아키텍처를 Arm으로 바꾸면 대략 30% 정도 비용을 절감할 수 있다.\n현재 근무중인 회사에서도 실제 Arm CPU를 굉장히 활발하게 사용하고 있다.\n신규로 생성하는 서비스는 물론 기존 AMD(x86)으로 동작중인 것들도 하나씩 마이그레이션 중이다.\n실제 수치상으로도 25~30% 정도의 비용 절감 효과를 보고 있다.\n다만, 이 경우 애플리케이션을 빌드할 때 Arm CPU 아키텍처에 맞춰서 빌드해야 한다.\n아무래도 이게 걸림돌이 되어서 전환을 망설이는 경우도 있을 것이다.\nDocker로 애플리케이션을 패키징 하고 있다면 Docker buildx를 사용 해보는 것을 추천한다.\n예를들어 다음은 회사에서 사용하고 있는 GitHub Action Workflow CI 스크립트중 하나의 일부이다.\n조금 특이하게도 대상 환경이 Production일 경우엔 arm64, Stage일 경우엔 amd64에 맞춰 빌드한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 # ...... # jobs: # ...... # release_ocir: # ...... # steps: # ...... # # 대상 Container Registry에 접근 권한 획득 - name: Login to OCIR repo uses: docker/login-action@v3.5.0 with: registry: ${{ steps.get-ocir-region.outputs.result }}.ocir.io username: ${{ secrets.OCI_TENANCY_NAMESPACE }}/${{ secrets.OCI_CLI_USER_NAME }} password: ${{ secrets.OCI_AUTH_TOKEN }} # 목표 환경 (prod/stage)에 따라 platform(CPU 아키텍처) 값 지정 - name: Resolve target platforms id: resolve-platforms run: |- TARGET=\u0026#34;${{ inputs.target }}\u0026#34; if [ -z \u0026#34;$TARGET\u0026#34; ]; then TARGET=\u0026#34;stage\u0026#34;; fi if [ \u0026#34;$TARGET\u0026#34; = \u0026#34;prod\u0026#34; ]; then echo \u0026#34;platforms=linux/arm64\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT else echo \u0026#34;platforms=linux/amd64\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT fi # Docker Buildx 설정 - name: Set up Buildx id: docker-buildx uses: docker/setup-buildx-action@v3.11.1 with: driver-opts: |- image=moby/buildkit:master network=host platforms: ${{ steps.resolve-platforms.outputs.platforms }} # 이미지를 빌드하고 대상 Registry에 Push - name: Build Container image and push uses: docker/build-push-action@v6.18.0 with: push: true context: . file: ./Dockerfile # 위에서 지정한 플랫폼(아키텍처)로 지정 platforms: ${{ steps.resolve-platforms.outputs.platforms }} tags: ${{ steps.get-ocir-repo.outputs.repo_path }}:${{ inputs.target || \u0026#39;stage\u0026#39; }}-latest,${{ steps.get-ocir-repo.outputs.repo_path }}:${{ inputs.target || \u0026#39;stage\u0026#39; }}-${{ steps.get-version.outputs.version }} # Buildx로 Builder 항목을 지정 builder: ${{ steps.docker-buildx.outputs.name }} cache-from: type=gha cache-to: type=gha,mode=max # ...... # Docker Buildx를 설정 해준 뒤 docker build action의 builder 값을 buildx의 이름으로,\nplatform은 원하는 타겟에 맞춰 빌드 후 레지스트리에 올리면 된다.\n대부분의 호환성 이슈는 Docker Buildx로 대응이 가능하지만, 이미지 내부에 설치하는 라이브러리 수준에서 문제가 있는 경우 Dockerfile에 분기를 넣어 해결한다.\n예를들어:\n1 2 3 4 5 6 7 # ...... # RUN if [ \u0026#34;$TARGETARCH\u0026#34; = \u0026#34;arm64\u0026#34; ]; then \\ # 아키텍처가 Arm64인 경우만 이 안의 코드를 실행 fi # ...... # 단순 분기 처리 정도로는 해결이 안 될 정도로 환경이 복잡하다면 아예 파일을 나눠 사용하기도 한다.\n1 2 3 . ├── Dockerfile.amd64 └── Dockerfile.arm64 Serverless 프레임워크 AWS에는 \u0026ldquo;Lambda\u0026rdquo;라는 서비스가 있다.\n24시간 실행되며 지속적으로 과금되는 EC2와 달리 Lambda는 서버 관리가 필요 없는 Serverless 방식이다.\n특정 코드가 실행될 때만 비용이 부과되어 새벽 시간대처럼 사용자가 거의 없는 유휴 시간에는 비용 부과가 되지 않는다.\n주요 특징은 다음과 같다:\n이벤트 기반(Event-Driven) 방식:\nCloudWatch, API Gateway와 연동하여 특정 이벤트,\n가령 API 호출, 일정 시간 경과, 파일이 업로드 등이 발생할 때만 코드가 실행된다.\n서버 관리 불필요:\n문자 그대로 Serverless 방식이다.\nEC2처럼 OS, H/W 용량 계획, 서버 프로비저닝, 스케일링 정책 등의 관리에 신경 쓸 필요가 없다.\n실행 기반 과금:\n코드가 실제로 실행되는 동안에만 비용이 부과된다.\n사용자가 있든 없든 지속적으로 시간당 비용을 부과하는 EC2와의 가장 큰 차이점이다.\n다만 몇 가지 단점도 있다:\n콜드 스타트 지연:\nLambda 함수가 장시간 호출되지 않았다면\nAWS가 런타임 환경을 준비하고 코드를 불러오는 초기 지연 시간이 발생한다.\n리소스 제약:\nLambda 함수는 최대 15분까지만 실행 될 수 있고 메모리는 10GB가 한계이다.\n구체적인 활용처:\n사용자의 요청에 즉각적인 응답이 필요하지 않은 API 서비스\nS3에 파일이 업로드 될 때 -\u0026gt; 이미지 썸네일, 동영상 트랜스 코딩, 데이터 필터링 등 데이터 처리 작업\n일정 시간이 되면(가령 매일 새벽 1시) -\u0026gt; 오래된 스냅샷/AMI 정리 등 관리 자동화 작업\n본격적인 Lambda 도입을 고려하고 있다면 AWS Serverless Appliaction Model에 대해 알아보도록 하자.\nJava, Python, Go등 다양한 프로그래밍 언어를 사용 할 수 있는 Lambda 기반의 프레임워크이다.\n특이한 점은 Serverless의 특성상 일반적인 API 서버 프레임워크랑은 다르게 각각의 독립된 코드들을\nCloudFormation을 통해 클라우드 리소스의 형태로 배포한다.\nCloudFormation은 AWS내 리소스를 yml 파일을 통해 구성/관리 할 수 있는 IaC 도구이다.\n처음 회사에 입사했을 때 Lambda를 쓰면 저렴하게 API 구성이 가능하다고 한 번 만들어 보라길래\n열심히 공부했던 기억이 난다. 그 덕에 IaC에 입문하게 되었다. (지금은 Terraform 쓰고 있지만\u0026hellip;)\n스팟 인스턴스 스팟 인스턴스는 AWS에서 현재 사용되지 않고 남아있는 여유 컴퓨팅 용량이다.\n이게 무슨 소리냐면 다른 사용자, 그러니까 다른 기업이 사용하고 있는 EC2 리소스 중\n현재 놀고있는 분량만큼을 빌려와서 사용한다는 개념이다.\n당연히 원래 주인이 내놓으라고 하면 돌려줘야 한다.\n비용은 기존 EC2 대비 최대 무려 90%나 저렴하다!\n좋아 보이긴 하는데 신뢰성에 의심이 가서 나도 개념만 들었지 실 도입은 못 해봤다.\n그래도 시의적절하게 잘 활용하면 괜찮은 대안이 될 것이다.\n장점:\n비용 절감:\n가장 큰 장점이다. On-Demand EC2 방식에 비해 70~90%까지 비용을 절약할 수 있다.\n대규모 컴퓨팅:\n낮은 비용으로 병렬 컴퓨팅 자원을 신속하게 확보할 수 있다.\n단점:\n회수 위험:\nAWS가 용량을 회수하면 언제든지 인스턴스가 중지 될 수 있다.\n일반적으로 중단 약 2분 전 Interruption Notice가 제공되고, 사전 분산을 위한 Rebalance Recommendation 신호도 제공된다.\n관리의 어려움:\n중단 가능성이 상시 존재하므로 체크포인트 저장, 워크 큐(재시도 가능),\n중간 결과를 S3/DynamoDB 등에 저장하는 설계가 필요하다.\n활용처:\n배치 처리, 빅데이터 분석, 머신 러닝\n기타 Stateless 서버의 부하 분산\n이미지/비디오 렌더링\n예약 인스턴스 EC2의 기본 요금은 흔히 On-Demand 방식이라고 한다.\n간단히 한국식으로 표현하면 후불 요금제이다.\n내가 어렸을 때는 PC방 1시간 사용료가 1,000원이었다.\n고로 5시간 게임을 하고 나서 요금 정산을 하면 5,000원을 내야했다.\n하지만 처음부터 5,000원을 내고 계정에 등록하면 6시간을 할 수 있었다.\n예약 인스턴스는 이와 유사한 방식이다.\n예약 인스턴스(Reserved Instance, RI)는 1년 혹은 3년동안\n특정 인스턴스 유형과 리전을 사용할 것을 미리 약정하고 선결제 혹은 부분 선결제 함으로써\nOn-Demand 방식에 비해 상당한 할인을 받는 방식의 요금 모델이다.\n마침 기존 포스트에서 조사한 정보가 있으니 여기에서 활용 해보겠다.\n다음은 AWS g5g.16xlarge 인스턴스의 비용 비교 표이다.\n비용모델 월 평균 사용료 (서울 리전전 / 단위 : USD) On-Demand $ 2388.29 Spot $ 672.59 1년 예약 분할 지불 $ 1559.73 1년 예약 일시불 $ 1455.74 3년 예약 분할 지불 $ 1102.4 3년 예약 일시불 $ 959.5 비교를 위해 On-Demand와 Spot도 추가 하였다.\nOn-Demand와 비교하면 3년 RI 일시불 방식이 60%가량 저렴하다.\n장점:\n비용 절감:\nSpot과 마찬가지로 엄청난 할인을 받을 수 있다.\nAZ 선점:\n이건 좀 특이한 케이스인데, 인스턴스 확보가 어려운 특정 AZ(가용 영역)에\n미리 용량 사용을 보장받을 수 있다.\n단점:\n장기 약정:\n1년 / 3년 이 2가지 옵션 밖에 없다. 그 기간 동안은 계속 써야한다.\n초기 비용 부담:\n할인을 받기 위해 처음 도입 시 엄청난 양의 비용을 지불해야 할 수도 있다.\n유연성 부족:\nStandard RI의 경우 인스턴스 유형을 변경할 수 없다.\nConvertible RI는 유형/패밀리 전환은 가능한데 할인율은 더 낮다. (On-Demand 대비 최대 72% -\u0026gt; 54%)\n활용처:\n장기적으로 운영 해온, 혹은 운영 될 것으로 예측되는 서비스\nk8s의 베이스 용량 확보:\nk8s 클러스터를 사용하고 있다면 상시 필요한 최소한의 용량이라는 것이 있을 것이다.\n보수적으로 접근해 최소 용량 수준에 맞춰 RI를 쓰면 비용 절감에 도움이 된다.\n절약 플랜 절약 플랜(Savings Plan)은 RI의 단점을 극복하기 위해 나온 요금제이다.\n기본적으로 RI와 유사한 비용 절감 효과는 최대한 제공하면서 부족한 유연성 제약을 완화한다.\n회사 근처 구내식당에서 점심 한 끼를 먹으려면 10,000원이라고 하자.\n직원이 10명 정도이고 한 달에 20일씩 출근을 한다면 한 사람이 최대 200,000원,\n10명이니 200만원 정도의 식비가 소모 될 것이다.\n만일 회사 차원에서 매달 100만원을 구내식당에 지불하면, 직원들의 식사를 무료로 해주겠다고 하면 어떠한가?\n절약 플랜은 이와 유사한 개념이다.\n다만 디테일하게 들어가면 굉장히 길고 복잡해서 이 부분만 추후 별도로 포스트 하도록 하겠다.\n기본적으로는 다음의 전제를 기억하자.\n리전, OS, 인스턴스 유형 패밀리 등의 제약이 많아질수록, 다시 말해\n유연성을 포기할수록 -\u0026gt; 비용이 절감된다\n종류:\nCompute Savings Plans\nEC2, Fargate, Lambda 등 컴퓨트 전반에 적용된다.\n리전/인스턴스 패밀리/OS 제약이 비교적 적어 유연성이 높다.\nEC2 Instance Savings Plans\n특정 인스턴스 패밀리 + 리전에 묶는 대신 할인율이 더 크다.\n주의사항:\n약정은 시간당 약정 금액 기준이다. 미사용 약정은 그대로 비용 처리된다.\n약정 기간은 1년/3년, 선결제 정도에 따라 할인율이 달라진다.\nRI보다 유연하지만, 약정 초과분은 온디맨드로 과금된다.\n절약 플랜은 예약 인스턴스에 비해 부담되는 제약사항이 덜하다.\n그덕에 경영진을 설득해 도입하는 게 비교적 쉬웠던 것으로 기억한다.\n더욱이 인스턴스 패밀리가 일치하는 모든 EC2에 구매한 절약 플랜이 일괄 적용되기 때문에\n바로 다음달부터 즉각적으로 회사에서 소비되는 인스턴스 비용이 25~30% 줄어들었다.\n다만 이는 절약 플랜의 제약조건에 해당하는 경우에만 그렇다는 거다.\n예를들어 현재 회사에는 인스턴스 유형이 t2 패밀리에 속하는 서버가 여럿 존재한다.\n현재 서비스 중인 t2 패밀리의 EC2 인스턴스 목록 이들은 내가 회사에 들어오기 전부터 있던 레거시 서버들인데,\n이런 저런 이유, 가령, 클라이언트나 해당 개발팀이 독립된 EC2 인스턴스를 원하거나,\nArm 아키텍쳐로의 전환, 심지어는 컨테이너화 자체에 소극적인 서비스들이 이곳에 위치한다.\n이 인스턴스들의 고정 비용을 줄이고자 \u0026ldquo;t2\u0026rdquo; 인스턴스 패밀리를 타겟하는 절약 플랜을 사용하고 있다.\n아이러니하게도 그게 Excuse가 되어 되려 다른 시스템, 가령 Arm 계열의 인스턴스 패밀리나 아니면 아예\n다른 클라우드 서비스 (우리의 경우 Oracle Cloud Infrastructure)로의 마이그레이션에 장애가 되곤 한다.\n그러니 환경에 발목 잡히지 않게 추후 계획을 충분히 고려해 도입에 신중을 기하도록 하자.\n구내식당 비유를 계속 들자면 본래 매일 점심 식사 하라고 회사에서 10,000원씩 지급 해주다가\n어느날부터 \u0026ldquo;앞으로 지정한 구내식당 가서 먹으면 무료니까 그쪽으로 가라\u0026rdquo; 라고 하는 것과 같다.\n이제 점심에 햄버거 먹고 싶으면 본인 돈으로 사야한다.\n햄버거를 좋아하는 나같은 몹쓸 인간은 슬퍼 하겠지만,\n이모님의 김치찌개 솜씨에 매료된 직원이라면 두 팔 벌려 환영할 것이다.\n스토리지 여기서 말 하는 스토리지는 블록 볼륨이나 RDS가 아닌 파일 저장소(일명 Bucket)를 의미한다.\nAWS의 경우 S3(Simple Storage Service)가 이에 해당된다.\nS3 비용을 절감하는 핵심 전략은 데이터의 접근 빈도에 따라 적절한 스토리지 클래스를 선택, 수명 주기(Lifecycle) 규칙을 활용하여 불필요한 데이터를 관리하는 것이다.\n스토리지 클래스 최적화 데이터 액세스 패턴에 맞는 스토리지 클래스를 선택하면 저장 비용을 크게 절감할 수 있다.\n클래스 주요 용도 특징/주의 S3 Intelligent-Tiering 접근 패턴이 불명확·변동이 잦은 데이터 계층 자동 이동(Frequent, Infrequent, Archive Instant, Archive, Deep Archive), 모니터링/자동화에 소액 월 비용 발생 S3 Standard-IA 드물게 접근하지만 밀리초 단위 즉시 조회 필요 장기 보관용, 백업/DR에 적합 S3 One Zone-IA 재생성 가능 데이터, 2차 백업 단일 AZ 저장으로 더 저렴, 내구성은 동일하나 가용성 리스크가 있음 S3 Glacier Instant Retrieval 1년에 몇 번 수준 접근 + 즉시 조회 필요 빠르게 조회 가능, 보관 단가 저렴 S3 Glacier Flexible Retrieval / Deep Archive 매우 드문 접근의 장기 아카이브 가장 저렴(Deep Archive), 검색 시간 느림 주의사항:\n적절한 주기 설정:\nIA/Glacier 계열은 최소 저장 기간(대략 30~180일 수준)과 조기 삭제 위약, 조회/복원 비용이 존재한다.\n전환 주기가 너무 짧으면 오히려 총비용이 증가할 수 있다.\n파일 크기:\nIntelligent-Tiering은 객체 크기/보관기간/접근 패턴에 따라 모니터링 비용 대비 이점이 작을 수 있다.\n작은 객체를 단기적으로 보관하는 것은 효율적이지 못하다.\nS3 수명 주기(Lifecycle) 규칙 활용 S3 수명 주기 규칙을 설정하여 자동으로 객체를 관리함으로써 비용을 절감할 수 있다.\n요컨대, 과거 데이터를 지우거나 압축하거나 액세스 빈도가 낮은 백업 스토리지로 옮기는 것을 의미한다.\n객체가 일정 기간이 지나면 액세스 빈도가 낮아질 것으로 예상하고, S3 Standard에서 Standard-IA, Glacier 등 저렴한 스토리지 클래스로 자동 전환되도록 규칙을 설정해주자.\n불필요한 객체 자동 삭제/만료:\n만료된 객체(Object Expiration): 더 이상 필요 없는 데이터(예: 오래된 로그 파일, 임시 파일)를 일정 기간 후 자동으로 삭제하도록 설정한다. 이전 버전 정리 (Versioning 활성화 시): 객체 버전을 활성화한 경우, 불필요하게 남아있는 이전 버전들을 일정 기간 후 영구 삭제하도록 설정한다. 불완전한 멀티파트 업로드 정리: 완료되지 않은 멀티파트 업로드 조각들이 남아있지 않도록 일정 기간 후 정리하는 규칙을 설정한다. 네트워크 (Data Transfer) AWS를 포함해서 클라우드 서비스에서 네트워크 사용 비용이란 건 기본적으로\n밖 -\u0026gt; 안(Inbound)으로 들어올 때는 문제가 없지만, 안 -\u0026gt; 밖(Outbound)으로 데이터가 전송되어 나갈 때 부과된다. 따라서 네트워크 비용을 낮추는 것은\n아웃바운드 데이터 전송(Data Transfer Out, DTO)을 줄이는 게 포인트이다.\nCDN 활용 CDN을 통해 컨텐츠를 캐싱하여 최종 사용자(ex: 페이지 방문자)에게 가장 가까운 엣지 로케이션을 제공함으로써\n서비스 품질도 올리고 비용도 줄이는 일거양득을 누릴 수 있다.\nAWS의 경우 CloudFront라는 서비스가 이에 해당한다.\n리전에서 직접 인터넷으로 데이터를 전송하는 것 보다 CloudFront를 통한 전송이 훨씬 저렴하다.\n캐싱된 데이터는 S3나 EC2에서 다시 전송할 필요가 없으므로 원본 서버의 DTO가 줄어든다.\n압축 전송 S3나 EC2에서 데이터를 전송할 때 Gzip이나 Brotli 등을 이용해 데이터를 압축해서 보내는 것이 좋다.\nCloudFront를 사용한다면 CloudFront에서 자동 압축을 켜는 것이 가장 간단하다.\nCloudFront의 동작 설정에서 압축(Compress Objects Automatically) 옵션을 \u0026ldquo;Yes\u0026quot;로 설정해주자.\n별도 CDN이 없거나 보다 세밀한 제어가 필요하다면 Origin(EC2, ALB 뒤의 웹 서버나 S3 등)에서 처리 해줘야 한다.\n이는 인프라 관리자가 할 일은 아니고, 각 애플리케이션을 만든 개발자의 몫이다.\n다행히 방법이 어렵진 않다.\n예를들어 Docker 빌드시 사용할 nginx 파일을 다음과 같이 작성 할 수 있다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 gzip on; gzip_static on; # filename.gz 존재 시, 클라이언트가 gzip 지원하면 직접 제공 brotli on; brotli_static on; # filename.br 존재 시, 클라이언트가 br 지원하면 직접 제공 brotli_comp_level 6; brotli_types text/plain text/css application/javascript application/json image/svg+xml; # 클라이언트 지원에 따라 우선 확장자 결정 (br \u0026gt; gzip) map $http_accept_encoding $precompress { ~*\\bbr\\b \u0026#34;.br\u0026#34;; ~*\\bgzip\\b \u0026#34;.gz\u0026#34;; default \u0026#34;\u0026#34;; } location / { try_files $uri$precompress $uri =404; # 지원 포맷 우선 제공, 없으면 원본 add_header Vary \u0026#34;Accept-Encoding\u0026#34;; expires 7d; } 내부 전송 최적화 클라우드 내부적으로 이루어지는 통신 비용도 절약 할 필요가 있다.\n동일 가용 영역(AZ)에 리소스 배치\n동일 리전 내에서도 가용 영역(AZ)이 다르면 전송 요금이 붙는다.\n통신이 잦은 인스턴스나 캐시같은 것들은 가급적 같은 AZ에 배치하자.\n클라우드 서비스 제공자마다 약간의 차이는 있지만 동일 AZ 내 트래픽은 대체로 무료거나 매우 저렴하다.\n⚠️ 여기서 잠깐!\n단, 데이터베이스와 같이 데이터 영속성이 그 무엇보다 중요한 경우에는 예외이다.\n이런건 가급적 멀티 AZ로 구성하는 걸 추천한다. (특히나 회사 생명이 걸려있다면)\nAZ라는건 결국 물리적인 데이터 센터인데, 다행히 아직까지 그런 경험은 없지만\n\u0026ldquo;혹여나 센터나 불이라도 나면 어쩌지?\u0026rdquo; 라는 걱정을 한시름 놓을 수 있을 것이다.\nVPC 내부 통신 활용\nEC2 인스턴스간 통신을 할 때 대상의 Public IP를 목적지로 해서 보낸다고 생각해보자.\n이는 마치 옆 집 사는 사람에게 물건을 전달하기 위해 우편을 붙이는 것과 같다.\n이 경우 통신 패킷 자체가 IGW(Internet Gateway)를 경유해 클라우드 밖으로 나갔다가\n다시 들어오는 게 되어 사실상 외부 통신으로 간주된다. 비용 부과는 물론 지연까지 발생한다.\n심지어 호출을 요청한 EC2가 Private Subnet에 있다면 문제는 더 심각해진다.\n이 때는 NGW(NAT Gateway)를 경유하게 되는데, 시간당 요금과 전송량 요금이 함께 부과된다.\n이는 월간 비용 누수의 최상위 원인이 되기 쉽다.\nS3, DynamoDB 등에 접근할 때도 마찬가지다.\n반드시 VPC 엔드포인트(Gateway Endpoint)를 사용해 내부망으로 우회하자.\n리전 간 전송 최소화\n가장 비싼 내부 전송은 리전 간(Region to Region)의 통신이다.\n데이터와 데이터를 소비하는 서비스를 가까운 리전에 두어 리전 간 복제 및 액세스를 최소화 하도록 하자.\n비용 모니터링/관리\nCost Explorer로 전송 비용을 서비스/리전/AZ 기준으로 추적하고,\n앞서 기술한 비용 할당 태그로 프로젝트 및 팀에 귀속시켜 원인을 파악하자.\n기타 서비스 Elastic Kubernetes Service (EKS) AWS에서 제공하는 KaaS(Kubernetes as a Service, 서비스형 k8s)이다.\n서비스 자체는 훌륭한데 역시나 가격이 문제다.\nKubernetes 버전 지원 티어 요금 표준 Kubernetes 버전 지원 시간 \u0026amp; 클러스터당 0.10 USD 확장 Kubernetes 버전 지원 시간 \u0026amp; 클러스터당 0.60 USD 원화로 바꾸면 표준형의 경우 1달에 약 10만원, 확장형은 60만원(!!)이 넘게 부과된다.\n지금 이건 Node 하나 없는 클러스터 1개의 자체적인 가격이다.\n대응전략:\nArm CPU, Spot, Savings Plan등 앞서 언급했던 내용들과 중복되는 것을 제외하겠다.\n클러스터 수 조절\nEKS 클러스터의 비용은 클러스터 개수에 비례한다.\n당연한 얘기지만, 사용하지 않는 클러스터에 대해서도 비용이 부과된다.\n특별한 이유가 있는게 아니라면\n하나의 리전에는 k8s 1~2개(환경 분리 목적) 정도만 배치하도록 하자.\n표준형(Standard)을 사용\n표준형과 확장형의 가격 차를(6배) 보고 확장형에 엄청난 어드밴티지가 있겠거니 생각하겠지만,\n사실 둘의 차이는 Kubernetes API의 지원 기간 정도가 전부다.\n보통 k8s는 3개의 마이너 버전에 대한 패치를 제공하는데 EKS는 이 지원 정책을 따라간다.\n표준형은 k8s에 대한 보안 패치, 버그 수정, 컨트롤 플레인 관리등을 표준 k8s 업데이트 사이클에 맞춰 하고,\n확장형은 여기서 1년을 더 사용할 수 있도록 연장해준다.\nEKS를 운영하는 가장 좋은 방법은 표준 지원 기간 내에 k8s 버전을 최신화 하는 것이다.\n확장형으로 돈 더 내면서 1년 유예를 둔다고 해도 그 기간마저 지나면 API 지원이 중단된다.\n그대로 방치해버리면 새로운 k8s 기능을 사용하지 못하거나 보안 위험에 노출 될 수도 있다.\n관심을 가지고 부지런하게 대응 해주도록 하자.\nCloudFront 앞서 얘기했듯 AWS의 CDN 서비스이다.\nHTML / CSS / JS 같은 파일을 S3에 두고 웹사이트를 호스팅해주는 데 주로 사용한다.\n이 역시 서비스 자체는 문제가 없는데 역시나 가격이 발목을 잡는다.\n요금 페이지 첫 장부터 진정한 '프리' 티어 어쩌구 하면서 유혹한다.\n조금만 더 내려보면 다음과 같다.\nCloudFront는 상시 무료(Always Free) 혜택으로 월 1TB 데이터 전송 아웃이 무료이며, 초과분부터 과금된다.\n대한민국이 인도 칼럼 옆에 다른 국가들과 함께 낑겨있는 걸 볼 수 있는데, GB당 $0.12라고 적혀있다.\n예시로 한국에 위치한 Cloudfront에서 한국에 있는 소비자들에게 1달에 3TB 정도의 데이터를 전송한다고 생각해보자.\n기본 1TB는 무료니 2,000 * 0.12\n계산해보면 달에 약 $240가 부과될 것임을 알 수 있다.\n대응전략:\n캐시 관리\n자주 바뀌지 않는 자산(기업 로고 등)은 TTL을 길게,\n자주 바뀌는 컨텐츠는 짧게 혹은 no-cache로 설정을 해주자.\nCloudFront는 요청 URL, 헤더, 쿼리 문자열 등을 기반으로 캐시 키를 생성한다.\n캐시 키에 불필요한 Query String이나 Header는 제외해서 하나의 키로 적중 될 수 있게 단순화 하자.\n원본 요청(Origin Fetch) 줄이기\n엣지에서 캐시 미스가 나도 원본에 곧장 가지 않도록 중앙 캐시 계층을 둬 부하를 줄이자.\n압축\n앞서 네트워크 섹션에도 기술했듯 Gzip등을 활용해 전송 데이터를 압축해서 보내도록 하자.\n다른 CDN을 고려하자\n위 방법들 이외에도 전략은 많지만,\n꼭 CloudFront에 매몰될 필요가 없다면 다른 CDN 서비스를 도입하는 것도 좋다.\n개인적으로 CDN은 Cloudflare를 추천한다.\n둘을 간단히 비교하면 다음과 같다 :\nCloudflare AWS CloudFront 네트워크 아키텍처 리버스 프록시 전통적인 CDN 기본 설정 방법 Nameserver Special URLs 주요 기능 CDN과 DDoS 방어 CDN 마켓 포지셔닝 Standalone 플랫폼 AWS 클라우드 서비스 중 일부 프리티어 과금 없음 매달 1TB 데이터 전송, 천만건 요청, 2백만건 함수 호출 무료 다만 이건 어디까지나 나의 \u0026ldquo;개인적인\u0026rdquo; 의견이다.\n다음과 같은 경우라면 CloudFront를 쓰는 게 낫다.\nAWS 생태계와(S3 ,EC2등) 긴밀하게 통합되길 원한다 웹 서비스가 대박이 나서 월 1,000TB 정도 트래픽이 나온다 (사용량이 올라갈수록 비용이 내려간다) ElasticIP (EIP) ElasticIP는 AWS에서 제공하는 고정 IP 리소스이다. 별 건 없고 EC2 Public IP를 고정할 때 주로 쓴다.\n문제는 AWS에서 Public IPv4 EIP의 경우,\n현재 할당을 했는지 안 했는지 여부에 관계 없이 시간당 $0.005를 부과한다는 것이다.\n계산해보면 한 달에 $3.6 ~ $3.72, 대략 $4 정도 부과된다. (그게 무슨 소리니 애떱아\u0026hellip;)\n뭐, 자기들 말로는 IPv4 주소가 전 세계적으로 고갈 되어감에 따라 내놓은 정책이라고 한다.\n주소 하나당 1달에 4달러가 별 거 아닌 거 같아도 이런게 많아지면 은근히 도트딜이 아프게 들어온다.\n대응전략:\n안 쓰는 ElasticIP는 지우도록 하자\nIPv6 주소를 발급받아 쓰자\nIPv6는 무료다. 워낙 대역폭이 넓어서 인류가 멸망할 때까지 써도 남아 돌 것이다.\n다만 IPv6 할당이 가능한 리소스는 한정되어 있다. 이 점을 유의하자.\nIPv4 문제는 다른 클라우드 서비스들도 대체로 상황은 비슷하다.\n마치며 최대한 간략하게 쓰려고 했는데, 하다보니 한도 끝도 없이 늘어져 버렸다. (분량 조절 실패)\nAWS에는 워낙 예상치 못한 숨겨진 추가 비용이 많아서 관리하는데 이만저만 품이 드는 것이 아니다.\n가장 좋은 접근법은 AWS에 너무 얽매이지 말고 AWS 이외의 다른 클라우드 서비스,\n혹은 On-Premise의 인프라를 한데 묶어 멀티 \u0026amp; 하이브리드 클라우드 인프라로 전환하는 것이다.\n다만 그러면서 따라오는 문제점도 있다.\n멀티/하이브리드 전환은 다음의 교환비용을 수반한다.\n벤더 종속성 감소 -\u0026gt; 관리 복잡성 증가(관제, 알림, 보안정책, 빌링 통합)\n비용 최적화 옵션 확대 -\u0026gt; 아키텍처/데이터 일관성 유지 비용 상승\n장애 내성·거점 다양성 -\u0026gt; 운영 인력/플랫폼 역량 요구 수준 상승\n현실적인 실행 순서는 아래와 같이 추천한다.\n비용 가시성 표준화:\n비용 할당 태그 강제와 팀별 Cost 대시보드 고정 배치\n낭비 차단 가드레일:\n미사용 리소스 자동 종료·알림, EIP·스냅샷 정리 정기화\n컴퓨팅 포트폴리오:\nArm 전환 검토 -\u0026gt; Serverless/스팟/RI·절약 플랜 혼합 적용\n데이터 등급화:\nS3 스토리지 클래스/라이프사이클로 자동 이전·만료 정책화\nDTO 최소화:\nVPC 엔드포인트, 동일 AZ 배치, 압축·CDN 캐시 키/TTL 최적화\n멀티·하이브리드 파일럿:\n단일 워크로드부터 이중화, 관제·빌링 통합 방식 검증\n결국 원칙은 단순하다.\n측정 → 표준화 → 자동화를 반복하며, 특정 환경에 과도하게 속박되지 않는 것이다.\n각 조직의 위험 허용도와 인력 역량에 맞춰 작은 실험부터 시작해보자.\n참고 자료\nAWS 비용 할당 태그 (Cost Allocation Tags) AWS EC2 스팟 인스턴스 개요 AWS EC2 예약 인스턴스(Reserved Instances) AWS EC2 Auto Scaling Groups 개요 AWS Savings Plans 개요 AWS Lambda 개요 Docker Buildx 문서 AWS S3 스토리지 클래스 개요 AWS S3 수명 주기(Lifecycle) 관리 AWS VPC 엔드포인트(Gateway/Interface) 개요 AWS NAT 게이트웨이 요금 AWS EKS 요금 AWS CloudFront 요금 AWS Elastic IP(IPv4) 문서 Prometheus Grafana Kubernetes HPA (Horizontal Pod Autoscaler) Kubernetes Cluster Autoscaler Helm 공식 문서 Cloudflare CDN ","date":"2025-11-01T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/how-to-save-on-cloud-costs/images/cover_hu_6fe643bac60f1f5f.png","permalink":"https://blog.ayteneve93.com/p/dev/how-to-save-on-cloud-costs/","title":"클라우드 비용 절약을 위한 노력"},{"content":"들어가기 앞서 며칠 전에 \u0026ldquo;오프라인 환경에서 RAG 앱 동작시키기\u0026rdquo;라는 포스트를 올렸다.\n마침 지난 금요일에 이 프로젝트가 마무리 되었는데 (포스팅 시간 기준으로는 이틀 전)\n대략적인 개요는 다음과 같다.\n자체 인공지능 + VectorStore가 포함된 온전히 동작하는 RAG 서비스를 하나 만들어라\nOS는 Windows 11 Pro이고\n하드웨어는 8GB VRAM GPU가 장착된 노트북이다.\n아, 그런데 이제 오프라인을 곁들인 (아예 와이파이 끊어버림)\n대략적인 시스템 구조 Windows, 노트북, 오프라인\u0026hellip; 거를 타선이 없다.\n그런데 결과는 의외로 잘 나와서 대표님도 만족하셨다.\n그렇다고 마냥 기뻐하고 있을 수는 없다. 이런 걸 진행한 의도가 무엇인지 파악해야 한다.\n듣자하니 추후 자체적인 AI 서버 도입을 희망하시는 듯 하다.\n본래 예전부터 신기한 기술 나오면 눈에서 붉은 안광을 뿜으시긴 했는데,\n이번엔 그게 AI인 모양이다.\n고민이 깊어져 간다\u0026hellip;\n서비스를 만드는 건 그렇다 쳐도 \u0026quot;인공지능 인프라를 어떻게 구성해야 하는가\u0026quot;가 주요 과제다.\n그리고 모든 이슈는 한 가지 단순한 사실로부터 시작되었다.\n하드웨어 가격이 너무 비싸다 내가 회사 인프라를 관리하면서 가장 조심스러워 하는게 바로 비용 문제이다.\n잘못되면 욕 먹기 제일 쉬운 분야라 눈에 불을 켜고 한 푼이라도 줄이기 위해 노력해야 한다.\n인공지능을 제대로 동작시키기 위해선 고성능의 병렬 연산 장치가 필수적인데 GPU(그래픽 처리 장치)건 NPU(신경 처리 장치)건 이런 하드웨어들은 하나같이 무지하게 비싸다.\nNPU는 나도 실물로 본 적이 없으니, GPU를 기준으로 생각해보면 데스크탑 PC 전체 가격의 심하면 50% 이상을 차지할 정도로 엄청난 몸값을 자랑한다.\n모델 필요한 하드웨어 사양은 어떤 모델을 사용하는가에 따라 크게 달라진다.\n마침 대표님이 눈독 들이고 있는 게 하나 있어 이를 예시로 쓰겠다.\n얼마 전, 8월에 공개된 gpt-oss이다.\n항목 gpt-oss-120b gpt-oss-20b 모델 크기 120B 20B 성능 o4-mini와 매우 유사한 성능 o3-mini와 매우 유사한 성능 아키텍처 128개의 전문 모델 통합 32개의 전문 모델 통합 활성 영역 동작 시 5.1B 크기 영역 활성화 동작 시 3.6B 크기 영역 활성화 권장 VRAM 약 66GB 약 13GB 권장 하드웨어 H100 80GB 하드웨어 1대로 원활하게 동작 16GB VRAM 이상의 그래픽카드로 원활하게 동작 참고로 H100 1개당 가격은 작성일 기준 4,000만원정도 한다. 😱\n노트북으로 먼저 테스트해보신 것으로 미루어 짐작하면, 여기선 gpt-oss-20b가 더욱 현실성이 있다.\n상용 GPU 사용시 HuggingFace에 올라온 gpt-oss-20b의 모델 정보를 보면 16GB VRAM 이상의 그래픽 카드를 쓰면 원활하게 쓸 수 있다고 한다.\n실제로는 Ollama뿐 아니라 Docling같이 보조 AI 서비스도 같이 올라갈 것으로 생각되기에,\n넉넉하게 그 2배인 32GB VRAM을 생각하고 있다.\n이러한 GPU는 1개당 대략 4~500만원 정도 나간다.\n클라우드 사용시 AWS에서 32GB VRAM이 장착된 g5g.16xlarge 인스턴스의 작성일 기준 1달 평균 사용 비용은 다음과 같다.\n온디맨드 : $2388.29\n쓴 만큼 내기, 후불제, 기본가 (최고가)\n스팟 : $672.59\n가장 저렴하긴 한데, 스팟을 쓰기엔 무리가 있다.\n필요할 때만 잠깐 쓰는 머신러닝이나 Docling 문서 전처리 정도의 작업을 담당하는 거면 모르겠으나,\n상용 서비스에는 부적합하다. 저렴한 데는 이유가 있다.\n설마 그러겠냐만 한창 서비스중에 인스턴스가 종료되기라도 하면 인생이 아주 스펙타클 해질 것이다.\n1년 예약 분할 지불 : $ 1559.73\n1년 예약 일시불 : $ 1455.74\n3년 예약 분할 지불: $ 1102.4\n3년 예약 일시불 : $ 959.5\n달러당 환율 1,400으로 계산하면 한달에 135만원이다.\n3년 일시불이니 총 지출은 대략 4,800만원(!!)이다. 그냥 H100 하나 사고 만다.\n전반적으로 보면 On-Premise 서버를 쓰는게 보다 합리적으로 보인다.\n물론 클라우드에 올라가는 서버용 연산장치를 단순히 상용 GPU와 VRAM 크기가 같다고 동일선상에 두는 건 불합리하다.\n더욱이 EC2의 인스턴스 타입은 CPU나 시스템 메모리 사이즈도 티셔츠마냥(large, x-large) 모두 딱딱 정해진 값이다.\n예시로 쓰인 g5g.16xlarge의 경우 Arm 64코어 CPU에 시스템 메모리 128GB가 적용된다.\n그 비용도 포함된 가격임을 고려해봐야 한다.\n클러스터 구성 앞서 H/W 가격과 이어지는 문제이다. 그렇다면 클러스터 구성을 어떻게 할 것인가?\n포인트는 GPU가 포함된 Node의 위치이다.\n싹 다 클라우드에 올리기 (상남자 스타일)\n문자 그대로의 의미이다.\n대표님이 Microsoft Azure에 3년 예약 GPU 인스턴스가 저렴하다고 한 번 써보자고 하시는데\u0026hellip;\n개인적으로 가장 편한 방법이긴 하나,\nGPU 사용료 비싼건 어느 클라우드를 쓰건 오십보백보라 그다지 추천하지는 않는다.\n전부 IDC에서 운영\nOn-Premise에 모든 Node를 배치하는 방법이다.\nIDC + 클라우드 \u0026mdash; Istio 다중 클러스터 서비스 메시\n클라우드에 k8s 하나, IDC에도 독립적인 On-Premise k8s 하나씩 두고 On-Premise에는 GPU Node들을 배치하는 방법이다.\n이상적이긴 한데 Istio 구성이 다소 복잡해질 것으로 보인다.\nEKS Hybrid Node\n이건 나도 이번에 조사해보다가 알게 된 건데, AWS EKS에 외부 Node를 등록하는 방식이다.\n요컨대 Control Plane은 여전히 AWS에 존재하고, Worker Node는 IDC에 둘 수 있다.\n당연히 무료는 아니고, 등록한 Worker Node의 코어당 연결 시간에 비례해서 책정된다.\n예를들어, 8 코어 16 쓰레드 CPU를 가지는 Node 1개를 연결하면 1달에 대략 33만원 정도가 나온다.\n단순 연결하는데 쓰는 비용이 이렇다면 당장의 현실성은 없다.\n그래도 이 접근방법의 개념은 되게 재밌어서 개인적으로 흥미가 많이 간다.\n자세한 내용은 AWS 공식 문서를 참고해보자.\nk8s에 Ollama 올려보기 고민거리를 늘어놓느라 서론이 길었는데, 결국 여태 내용 중 아직 확정된 것은 없다. (사실상 푸념)\n아무래도 큰 비용이 걸려있는 일이다보니 주말에 나 혼자 머리 싸맨다고 해결될 리 만무하다.\n그래서 일단은 당장 할 수 있는 걸 해보기로 했다.\n바로 내가 개인적으로 운영하는 On-Premise 클러스터에 Ollama 서비스를 올려보는 것이다.\n사용할 GPU 이번에 고생해줄 GPU는 NVIDIA의 RTX 2080이다.\n왕년에는 끗발 좀 날리던 녀석이었는데,\n릴리스(디아블로 IV)의 권능 앞에 무너져 새 GPU(RTX 4070)에 자리를 내주고 쉬고 있었다.\n그러다 몇 달 전에 Jellyfin이라는 서비스의 ffmpeg GPU 가속을 위해 싹 분해해서\n먼지 청소도 해주고 써멀 패드도 갈아주고 팬도 전부 새걸로 바꿔 끼웠다.\n분해 당시 부식이나 냉납도 없었다. 고로 사용하는데 지장은 없을 것이다.\n대략적인 스펙은 다음과 같다.\n항목 내용 NVIDIA 칩셋 RTX 2080 인터페이스 PCIe3.0×16 CUDA 프로세서 2944개 메모리 종류 GDDR6 메모리 용량 8GB Nvidia GPU Operator 설치 Ollama에 GPU를 연결해야 하므로 Nvidia GPU Operator를 클러스터에 설치해줘야 한다.\nAMD GPU를 사용한다면 AMD GPU Operator를 설치해야 하는데, 이번 포스트에서는 Nvidia를 기준으로 하겠다.\nNvidia Driver 설치 우선 GPU Node에 접속해서 Nvidia Driver부터 설치해야 한다.\nOS나 CPU Architecture에 따라 방법이 천차만별인데, 일단 가장 보편적인 Ubuntu를 기준으로 하겠다.\n환경별 설치 방법은 이 문서 참조.\n1 2 3 sudo add-apt-repository ppa:graphics-drivers/ppa sudo apt update sudo ubuntu-drivers autoinstall 설치가 완료되면 시스템을 재부팅해준다.\n1 sudo reboot 재부팅 후 다음의 명령어를 입력해보자.\n1 nvidia-smi 다음과 같이 출력되면 정상이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Mon Oct 27 01:57:28 2025 +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 570.195.03 Driver Version: 570.195.03 CUDA Version: 12.8 | |-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 0 NVIDIA GeForce RTX 2080 Off | 00000000:29:00.0 Off | N/A | | 0% 30C P8 2W / 240W | 1MiB / 8192MiB | 0% Default | | | | N/A | +-----------------------------------------+------------------------+----------------------+ +-----------------------------------------------------------------------------------------+ | Processes: | | GPU GI CI PID Type Process name GPU Memory | | ID ID Usage | |=========================================================================================| | No running processes found | +-----------------------------------------------------------------------------------------+ 네임스페이스 생성 1 2 3 kubectl create ns gpu-operator kubectl label --overwrite ns gpu-operator pod-security.kubernetes.io/enforce=privileged kubectl get ns --show-labels | grep gpu Nvidia GPU Operator가 설치될 네임스페이스를 만든 후 네임스페이스 내 pod의 보안정책을 privileged로 설정해 GPU 접근을 허용해야 한다.\nHelm Repository 추가 1 2 helm repo add nvidia https://helm.ngc.nvidia.com/nvidia helm repo update values 설정 다음의 커맨드로 기본 차트 values 값을 가져와 로컬에 저장해보자.\n1 helm show values nvidia/gpu-operator \u0026gt; nvidia-gpu-operator.yml nvidia-gpu-operator.yml에 보면 여러 옵션들이 있는데, 몇 가지 수정해줘야 할 것이 있다.\nDriver 설정 변경\n1 2 3 4 5 6 7 8 9 10 # 위에서 이미 Nvidia Driver를 설치했으므로 제외 driver: enabled: false # 이 아래 옵션은 H100 등에서만 사용 가능하다 migManager: enabled: false vgpuDeviceManager: enabled: false vfioManager: enabled: false Toolkit 설정 변경\n나는 containerd를 기본 Runtime으로 사용하고 있어서 다음과 같이 설정해주었다.\n1 2 3 4 5 6 7 8 9 10 11 12 operator: defaultRuntime: containerd # 기본 런타임을 containerd로 설정 toolkit: enabled: \u0026#34;true\u0026#34; env: - name: CONTAINERD_CONFIG value: /etc/containerd/config.toml - name: CONTAINERD_SOCKET value: /run/containerd/containerd.sock - name: CONTAINERD_SET_AS_DEFAULT value: \u0026#34;1\u0026#34; 차트 배포 1 2 3 helm install gpu-operator nvidia/gpu-operator \\ -n gpu-operator \\ -f ./nvidia-gpu-operator.yml Ollama 설치 Ollama도 Helm으로 설치할 수 있다.\n네임스페이스 생성 1 kubectl create namespace ollama AI 모델을 저장할 PVC 생성 1 2 3 4 5 6 7 8 9 10 11 12 # ollama-pvc.yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ollama-models-pvc namespace: ollama spec: accessModes: - ReadWriteOnce resources: requests: storage: 30Gi 1 kubectl apply -f ollama-pvc.yaml Helm Repository 추가 1 2 helm repo add ollama https://ollama.ai/kubernetes/ollama helm repo update values 설정 Nvidia GPU Operator와 마찬가지로 values를 가져와 살펴볼 수 있다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # ollama.yml ollama: gpu: enabled: true # GPU 사용 여부 runtimeClassName: nvidia # nvidia runtime 사용 (Nvidia GPU Operator) persistentVolume: enabled: true existingClaim: ollama-models-pvc # (선택) GPU가 설치되어 있는 Node에만 파드가 스케쥴링 되도록 하기 affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: nvidia.com/gpu.present operator: Exists 여담으로 Helm Chart를 통해 AI Models를 관리하는 것도 가능하다.\n예를 들어:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ollama: models: pull: - deepseek-r1:8b # DeepSeek 모델을 다운로드 run: - deepseek-r1:8b # 원하는 모델을 메모리에 상주 # 혹은 다음과 같은 방식으로 커스텀 모델 생성도 가능 create: - name: deepseek-r1-ctx32768 template: | FROM deepseek-r1:8b PARAMETER num_ctx 32768 clean: false # 이 값을 true로 하면 위에서 지정한 모델만 남기고 다 지운다 그런데 그다지 추천하지는 않는다.\nAI 모델 변경이 의외로 자주 일어나는데 그때마다 Helm 차트를 다시 배포하는 건 꽤나 번거롭다.\n차트 배포 1 helm install ollama ollama/ollama -f ollama.yml -n ollama Open Web UI 설치 Ollama Chart는 오로지 Ollama만 설치해준다.\n직접 테스트해볼 수 있게 UI도 별도 Chart로 배포했다.\n기본적인 AI 인프라만 필요하다면 이 섹션은 무시해도 된다.\n네임스페이스 생성 1 kubectl create namespace open-webui 환경 설정을 저장할 PVC 생성 1 2 3 4 5 6 7 8 9 10 11 12 # open-webui-pvc.yml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: open-webui-pvc namespace: open-webui spec: accessModes: - ReadWriteOnce resources: requests: storage: 2Gi 1 kubectl apply -f open-webui-pvc.yaml Helm Repository 추가 1 2 helm repo add open-webui https://open-webui.github.io/helm-charts helm repo update values 설정 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 # open-webui-values.yml ollama: enabled: false # Ollama 설치 안 함 # 이 부분이 좀 의아할텐데 Open Web UI 차트에서는 # 내부적으로 Ollama를 설치하는 것도 가능하다. # 여기에 위 Ollama 차트의 설정을 그대로 넣으면 가능하다. # 하지만 가급적 분리해서 배포하는 걸 추천한다. # 자체 Ollama를 안 쓰기 때문에 UI가 연결할 Ollama의 URL을 지정해줘야 한다 # FQDN을 다 쓸 필요는 없고 http://\u0026lt;서비스\u0026gt;.\u0026lt;네임스페이스\u0026gt;:\u0026lt;포트\u0026gt;로 적으면 된다. # 서비스와 네임스페이스는 모두 \u0026#34;ollama\u0026#34;이고 포트는 기본적으로 11434이다. # 따라서 \u0026#34;http://ollama.ollama:11434\u0026#34;를 적어준다. ollamaUrls: - http://ollama.ollama:11434 # (선택) Tika 설정 tika: enabled: true # (선택) Health Check 설정 livenessProbe: httpGet: path: /health port: http failureThreshold: 1 periodSeconds: 10 readinessProbe: httpGet: path: /health/db port: http failureThreshold: 1 periodSeconds: 10 startupProbe: httpGet: path: /health port: http failureThreshold: 20 periodSeconds: 5 initialDelaySeconds: 30 # (선택) Ingress 설정 ingress: enabled: true class: nginx host: \u0026#39;\u0026lt;보유한 도메인 레코드, 가령 ai.example.com\u0026gt;\u0026#39; # Persistence 설정 persistence: existingClaim: open-webui-pvc 차트 배포 1 2 3 helm install open-webui open-webui/open-webui \\ -f open-webui-values.yaml \\ -n open-webui 동작 테스트 ingress 설정을 해두었다면 설정한 domain으로, 별도 설정을 안 했다면\n다음의 커맨드로 포트포워딩 후 localhost:8080으로 접속해보자.\n1 kubectl port-forward -n open-webui svc/open-webui 8080:80 간단하게 관리자 설정을 마치고 로그인한 뒤\n우측 상단의 프로필 클릭 -\u0026gt; 관리자 패널 -\u0026gt; 설정으로 들어가보자.\n혹은 단순히 \u0026lt;Open Web UI Base URL\u0026gt;/admin/settings/general로 들어갈 수 있다.\n굉장히 다양한 옵션이 있다.\n우선 기본 동작 테스트를 해야하므로, 모델로 이동해 AI 모델 하나를 다운로드 받아보도록 하자.\nOllama.com 검색 페이지에 접속해서 원하는 모델을 찾아보자\nAI 모델이 워낙 종류가 다양해서 뭐 하나 고르는 게 쉽지는 않다.\n몇 가지 추천을 해보자면 다음과 같다.\nNaver Clova\nLG Exaone Deep\nGemma3\n하드웨어 사양과 취향에 맞춰 원하는 모델을 다운로드 받아보자.\n내 경우 exaone-deep:7.8b란 모델을 다운로드 받았다.\n모델 다운로드가 완료되면 간단하게 AI와 채팅해볼 수 있다.\n마치며 이번 포스트에서는 Kubernetes 클러스터에 Ollama AI 서버를 구축하는 과정을 다뤄봤다.\nNvidia GPU Operator 설치부터 Ollama, Open Web UI 배포까지 전 과정을 Helm 차트를 통해 간단하게 구성할 수 있었다.\n물론 실제 상용 환경에서는 앞서 언급한 비용 문제와 클러스터 구성 방식에 대한 고민이 필요하다.\n특히 GPU 하드웨어의 높은 가격대와 클라우드 사용료는 여전히 큰 부담으로 남아있다.\n하지만 이번 실험을 통해 얻은 경험은 향후 실제 AI 인프라 도입 시 큰 도움이 될 것 같다.\nOn-Premise 환경에서도 충분히 동작하는 것을 확인했으니, 비용 효율성을 고려한 하이브리드 구성도 충분히 검토해볼 만하다.\n추후 계획 포스트에는 굳이 명시하지 않았는데, Ollama Chart 역시 별도의 Ingress 설정을 해주었다.\n사용한 도메인 레코드는 ollama.ayteneve93.com.\nOAuth2 Proxy를 보안 레이어로 감싸주고 Terraform이 랜덤하게\n생성한 OAuth Bypass Key 헤더를 설정, 인증된 사용자라면 외부에서도 들어올 수 있게 환경을 마련하였다.\n테스트로 설치한 Ollama지만 일단 자원을 잡아먹고는 있으니 이걸 앞으로 어떻게 활용할까도 생각해봐야 한다.\n가령:\nCursor와 같은 인공지능 IDE와 연결한다거나\ndeepseek-coder 모델을 사용해서 코드 분석을 맡겨본다거나\nllava 같은 Vision 모델을 사용해서 그림을 그리게 시킨다거나\n또한 DCGM Exporter 차트를 배포해 Prometheus에서\nGPU 사용량을 모니터링할 수 있도록 할 계획이다.\n참고 자료\nNvidia GPU Operator 공식 문서 Ollama Kubernetes Helm Chart Open Web UI Helm Chart ","date":"2025-10-26T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/install-ollama-to-k8s/images/cover_hu_331eeae97f6b202d.png","permalink":"https://blog.ayteneve93.com/p/dev/install-ollama-to-k8s/","title":"k8s에 Ollama AI 서버 올려보기"},{"content":"연관 포스트 DevContainer 톺아보기 예제 프로젝트 GitHub 링크 postExample.noVncDesktopLiteFeature 들어가기 앞서 얼마 전에(어제) \u0026ldquo;DevContainer 톺아보기\u0026rdquo;라는 제목의 포스트를 올렸다.\n주된 내용을 요약하면 다음과 같다.\n개발 환경 구축 시 겪는 어려움(일관성, 재현성, 격리성) 소개\nDevContainer를 사용해서 이런 문제를 극복할 수 있다는 것\n기본적인 설치 방법과 약간의 심화 과정\n본래 이번 포스트를 먼저 작성하고 있었는데,\n쓰다보니 DevContainer 자체에 대한 내용이 지나치게 장황하다라는 것을 깨달았다.\n고로 내용을 둘로 나눠 DevContainer에 대해 정리하는 포스트를 먼저 올린 것이다.\n이거 보여주려고 어그로 끌었다\n문제의 시작 내겐 너무 무거운 Docker 내가 작업할 때 사용하는 PC는 총 3대이다.\n회사에 있는 Windows Desktop\n집에 있는 Windows Desktop\n회사에서 지원받은 맥북\n처음에 DevContainer를 구성했을 때는 각 PC에 Native하게 Docker 환경을 구축해서 사용했었다.\nWindows는 WSL과 DockerDesktop, Mac은 DockerDesktop만 설치하면 되는 아주 간단한 작업이다.\n그러다 이슈가 발생했다.\n이 Docker라는 녀석\u0026hellip; 컴퓨터의 자원을 엄청나게 소모한다.\n그도 그럴 것이 내가 실행하길 원하는 건 어디까지나 Linux Container이고 근본적으로 OS Level부터 다른\nMac이나 Windows에서는 어떤 방식으로든 Linux를 가상화시켜 Container를 띄워야만 한다.\n다시 말해 Linux와 Docker가 하이퍼바이저 위에서 동작한다는 의미이다.\n그나마 Windows PC는 둘 다 Desktop이고 WSL을 통해서 하는 거라 비교적 견딜만 하다.\n진짜 문제는 Mac이다.\n나름 MacBook Pro인데도 불구, 조금 무거운 DevContainer 2~3개 정도만 띄워도\n방열 팬에서 당장이라도 이륙할듯한 기세로 고열의 제트를 내뿜는다. F-35가 따로 없다.\n심한 날은 격한 스로틀링 끝에 아예 Mac이 Docker를 죽여버리는😱 경우도 심심찮게 발생한다.\n내 개인적인 평은 \u0026quot;Abstraction Layer가 쌓일 수록 자연스레 발생하는 오버헤드\u0026quot;이다.\n요컨대\n사람이 편해지면 기계가 고생한다\n별도 Linux 서버를 사용하기로 함 사실 이런 문제는 특별히 Mac이라 그런 것은 아니다. 애플은 억울합니다\n그보단 노트북이라는 환경 자체가 발열 해소에 취약할 수 밖에 없다는 구조적 이슈이다.\n내가 택한 해결책은 단순하다. 그냥 개발용 Linux 서버 하나를 구축하는 것.\n추후 기회가 되면 그 구체적인 과정에 대해 포스트 하겠지만 아주 간단하게 요약하면 다음과 같다.\n안 쓰는 Desktop PC 1대를 준비한다.\n원하는 Linux OS를 설치한다.\nDocker와 Docker Compose를 설치한다.\n외부에서 접속 가능하도록 SSH 서버를 설치한다.\nWOL을 통해 원격으로 전원을 켤 수 있도록 설정한다. (선택)\n이렇게 해두면 사용하는 로컬 PC에는 vsCode나 Cursor 정도만 설치하면 그만이다.\nIDE에서 SSH로 원격 개발 서버에 연결 -\u0026gt; 프로젝트를 Clone하고 -\u0026gt; 개발 컨테이너 시작!\n실질적인 작업은 모두 원격 개발 서버에서 실행되므로 내가 지금 사용하는 PC에는 아무런 부담도 없는 것이다.\n그러다\u0026hellip; 새로운 문제에 직면했다.\n크롤링(Crawling) 크롤링은 웹상의 정보를 탐색하고 수집하는 작업이다.\n외부 서버에서 데이터를 가져와야 하는데, 상황에 따라 접근 방법이 달라진다.\n별도로 API를 제공하는 경우\n제공하는 API를 그대로 사용하면 된다. 가장 이상적인 상황이다.\n정적 페이지로 구성되어 있는 경우\n현재 이 블로그처럼 별도의 SiteMap을 통해 페이지 목록 확인이 가능한 경우가 많다.\n해당 URL을 통해 HTML을 가져와 파싱하면 된다. curl, axios 등을 사용해서 간단하게 처리 가능하다.\n동적 페이지로 구성되어 있는 경우\n이런게 문제가 된다. 흔히 웹 페이지가 아니라 웹 애플리케이션이라고 더 많이 칭하는데,\n일련의 정보보단 서비스를 제공하는데 더 초점을 맞춘 경우다.\nHeadless 브라우저를 열어서 직접 페이지에 방문, 데이터를 추출 및 파싱해야 한다.\n그런데 돌연 이게 DevContainer와 무슨 관계가 있다는 걸까?\nGUI를 볼 수가 없다 DevContainer에서 GUI가 포함된 어떤 프로그램을 실행해야 하는 경우가 아주 간혹 있다.\n그 중에 비교적 가장 빈번한 것이 앞에서 언급한 브라우저를 통한 크롤링이다.\n회사에서 맡게 되는 작업 중에는 이렇게 브라우저로 외부 웹 페이지를 방문해서 데이터를 추출해야 하는 때가 있는데, 크롤링 자체는 DevContainer건 서버에 올린 Production Container건 별 문제 없이 아주 잘 동작한다.\n문제는 디버깅이다.\n브라우저라는 것은 일종의 GUI 프로그램이다.\n스크립트가 브라우저를 띄울 때는 대개 headless모드로 동작하는데,\n이는 브라우저 그래픽은 띄우지 말고 백그라운드에서 실행하라는 의미이다.\n개발을 할 때는 이 headless 모드를 꺼놓고 작업을 해야한다.\n직접 브라우저에서 Bot이 동작하는 모습을 보면서 스크립트를 짜야 하니까\u0026hellip;\n그런데 DevContainer에서는 브라우저의 GUI를 볼 수가 없다.\n내 기억이 맞다면 WSL 위에 올린 DevContainer에서는 가능 했던 거 같은데, 정확하지 않다.\n확실한 건 지금 사용하는 방식, 원격 개발 서버의 DevContainer에서는 GUI를 볼 방법이 없다는 것이다.\n해결방안 \u0026ldquo;문제의 시작\u0026rdquo; 섹션을 길게 쓴 것에 비하면 해결방안은 의외로 매우 심플하다.\n기존 포스트의 \u0026ldquo;Features로 개발도구 설치하기\u0026rdquo; 단락에 보면\n여러가지 개발 도구들을 간편하게 설치할 수 있다는 걸 알 수 있다.\n사용 가능한 기능 목록 페이지에 있는 \u0026ldquo;Lightweight Desktop\u0026rdquo;을 설치해서 브라우저를 통해 DevContainer에서 실행되는 GUI 프로그램을(이 경우, 브라우저) 눈으로 확인할 수 있도록 해보겠다.\nDevContainer 구성 이번 포스트에선 이 문제를 해결하는 예시 소스가 담긴 GitHub Repository를 아예 따로 만들었다.\n1 git clone https://github.com/ApexCaptain/postExample.noVncDesktopLiteFeature.git 직접 작업할 시간이 없다면 위 커맨드로 프로젝트를 Clone 해서 봐도 좋다.\ndevcontainer.json\nLite-weight Desktop Feature에 따르면 사용 가능한 옵션은 다음과 같다.\nOptions Id Description Type Default Value version Currently Unused! string latest noVncVersion The noVNC version to use string 1.2.0 password Enter a password for desktop connections. If \u0026ldquo;noPassword\u0026rdquo;, connections from the local host can be established without entering a password string vscode webPort Enter a port for the VNC web client (noVNC) string 6080 vncPort Enter a port for the desktop VNC server (TigerVNC) string 5901 이에 따라 .devcontainer/devcontainer.json 파일을 다음과 같이 구성해보자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 // .devcontainer/devcontainer.json { // Basic \u0026#34;name\u0026#34;: \u0026#34;PostExample.noVncDesktopLiteFeature Dev Container\u0026#34;, \u0026#34;dockerComposeFile\u0026#34;: \u0026#34;docker-compose.dev.yml\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;workspace\u0026#34;, \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/postExample.noVncDesktopLiteFeature\u0026#34;, // 혹은 다음과 같이 적어도 된다 // \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/${localWorkspaceFolderBasename}\u0026#34;, // Featuring \u0026#34;features\u0026#34;: { // https://github.com/devcontainers/features/tree/main/src/desktop-lite \u0026#34;ghcr.io/devcontainers/features/desktop-lite:1\u0026#34;: { // 기본 설정값들 // \u0026#34;version\u0026#34;: \u0026#34;latest\u0026#34;, // \u0026#34;noVncVersion\u0026#34;: \u0026#34;1.2.0\u0026#34;, // \u0026#34;password\u0026#34;: \u0026#34;vscode\u0026#34;, // \u0026#34;webPort\u0026#34;: \u0026#34;6080\u0026#34;, // \u0026#34;vncPort\u0026#34;: \u0026#34;5901\u0026#34; }, // https://github.com/devcontainers/features/tree/main/src/node // Puppeteer로 테스트 할 예정이므로 nodejs를 설치 \u0026#34;ghcr.io/devcontainers/features/node:1\u0026#34;: {} }, // Ports // Desktop Lite의 기본 WebSocket 포트를 포워딩 \u0026#34;forwardPorts\u0026#34;: [ 6080 ], \u0026#34;portsAttributes\u0026#34;: { \u0026#34;6080\u0026#34;: { \u0026#34;label\u0026#34;: \u0026#34;Desktop (noVNC)\u0026#34; } } } docker-compose.dev.yml\n1 2 3 4 5 6 7 8 9 10 11 # .devcontainer/docker-compose.dev.yml services: workspace: container_name: post_example_novnc_desktoplitefeature_devcon_workspace image: mcr.microsoft.com/devcontainers/base:bullseye # Shared Memory의 크기를 1GB로 설정 shm_size: \u0026#39;1gb\u0026#39; volumes: # Workspace Cache - ..:/home/vscode/postExample.noVncDesktopLiteFeature:cached command: sleep infinity 이걸로 DevContainer 구성은 끝났다.\nF1 키를 누르고 Dev Containers: Rebuild Container를 실행하자.\nDevContainer 빌드가 끝났다면,\nHost PC에서 브라우저를 열고 localhost:6080으로 접속해보자.\n이 화면까지 봤다면 성공이다 🎉 좀 전에 설치한 Desktop Lite에 웹을 통해 접속한 것이다. 여기서 연결 버튼을 눌러주자.\nDevContainer 내부에서 실행되는 GUI를 볼 수 있을 것이다.\n크롤링 예제 프로젝트 이번 예시에선 네이버 뉴스 페이지에 접속해서\n가장 좌측 상단에 있는 칼럼의 타이틀과 내용을 추출하는 간단한 크롤링 스크립트를 짜보도록 하겠다.\n프로젝트 초기화 및 의존성 설치\nyarn 명령어로 nodejs 프로젝트를 초기화한다.\n1 yarn init -y 생성된 package.json의 type을 module로 설정한다.\n1 2 3 4 5 6 { \u0026#34;name\u0026#34;: \u0026#34;post-example.no-vnc-desktop-lite-feature\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0.0\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;module\u0026#34;, // Type을 Module로 지정 \u0026#34;main\u0026#34;: \u0026#34;index.js\u0026#34; } Puppeteer와 Cheerio를 설치한다.\n1 2 3 yarn add \\ puppeteer \\ cheerio 스크립트 작성\nindex.js 파일 하나를 만들고 다음의 스크립트를 복사해보자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 // index.js import puppeteer from \u0026#39;puppeteer\u0026#39;; import * as cheerio from \u0026#39;cheerio\u0026#39;; const main = async () =\u0026gt; { let browser; try { // #1 Puppeteer 인스턴스 생성 browser = await puppeteer.launch({ // Browser를 GUI 없이 사용할지 여부, 운영 환경에선 true로 설정 해주자 headless: false, args: [ \u0026#39;--no-sandbox\u0026#39;, \u0026#39;--disable-setuid-sandbox\u0026#39;, ], }); // #2 새로운 페이지 (탭) 생성 const page = await browser.newPage(); // #3 네이버 뉴스 페이지 이동 await page.goto(\u0026#39;https://news.naver.com/\u0026#39;, { waitUntil: \u0026#39;networkidle0\u0026#39;, timeout: 30000 // 30초 타임아웃 }); // #4 대상 뉴스 카드 element selector를 선언하고 화면에 보이면 클릭 const newsCardElementSelector = \u0026#39;#ct \u0026gt; div \u0026gt; section.main_content \u0026gt; div.main_brick \u0026gt; div \u0026gt; div:nth-child(1)\u0026#39; // 확실히 화면에 들어올 때까지 대기 (10초 타임아웃) await page.waitForSelector(newsCardElementSelector, { visible: true, timeout: 10000 }); await page.click(newsCardElementSelector); // #5 대상 뉴스의 제목과 본문의 텍스트를 추출 const titleElementSelector = \u0026#39;#title_area \u0026gt; span\u0026#39; const articleElementSelector = \u0026#39;#newsct_article\u0026#39; await page.waitForSelector(titleElementSelector, { visible: true, timeout: 10000 }); const $ = cheerio.load(await page.content()); const title = $(titleElementSelector).text(); const article = $(articleElementSelector).text(); // #6 제목과 본문의 텍스트를 콘솔에 출력 console.log({ title, article }) } catch (error) { console.error(\u0026#39;에러가 발생했습니다:\u0026#39;, error.message); } finally { // #7 브라우저 인스턴스 종료 (에러가 나도 반드시 실행) if (browser) { await browser.close(); } } }; main(); 실행 이제 작성한 스크립트를 실행해보자.\n1 node index.js 출력되는 결과 자체는 그때마다 다를 것이다.\n이제 Desktop Lite를 통해 보면 어떠한지 확인해보자.\n브라우저를 통해 브라우저가 실행되는 걸 바라보는 모습이다 그리고 그걸 녹화해서 또 다른 브라우저로 보고있는 나 주의사항 호환성 나온 지 얼마 안 된 기능이라 그런지 OS 및 하드웨어 제약사항이 있다.\nContainer Image 제약\n예시에서 사용한 DevContainer Docker Image는 Debian 기반이다.\ndesktop lite feature는 현재 Debian/Ubuntu 기반의 이미지만 지원한다.\nAlpine 이미지에서는 사용할 수 없다.\nCPU Architecture 제약\ndesktop lite feature를 적용하려면 DevContainer를 동작하는 PC의\nCPU Architecture가 AMD64여야만 한다.\nIntel이나 AMD에서 만든 CPU라면 관계 없으나,\nArm을 사용하고 있다면 이 기능은 사용할 수 없다.\n대표적으로 Apple Silicon 칩이 장착된 Mac은 안 된다.\n패스워드 설정 desktop lite feature는 내부적으로 VNC 서버와\n그 위에 얹혀 있는 noVnc 웹 클라이언트를 사용해서 GUI 화면을 노출시킨다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 +---------------------------+ | Web Browser | | (noVNC Client over 6080) | +------------▲--------------+ │ WebSocket ▼ +---------------------------+ | noVNC (Websockify) | +------------▲--------------+ │ VNC Protocol ▼ +---------------------------+ | Xvfb + Fluxbox | | (Virtual Display Server) | +------------▲--------------+ │ ▼ +---------------------------+ | GUI Application (ex: Chrome) | +---------------------------+ 그리고 .devcontainer/devcontainer.json에서 해당 포트를(6080) 그대로 포워딩 한다.\n1 2 3 4 5 6 7 8 9 10 11 12 // .devcontainer/devcontainer.json { // ... \u0026#34;forwardPorts\u0026#34;: [ 6080 ], \u0026#34;portsAttributes\u0026#34;: { \u0026#34;6080\u0026#34;: { \u0026#34;label\u0026#34;: \u0026#34;Desktop (noVNC)\u0026#34; } } } 로컬 PC에 DevContainer를 실행중이라면 큰 문제가 되진 않겠지만,\nRemote Devcontainer를 사용하는 경우, 가령\nSSH (자체 호스팅 혹은 클라우드 서버) Codespaces vsCode 혹은 Cursor는 원격 서버의 6080 포트를 \u0026ldquo;클라이언트 측으로 포워딩\u0026rdquo; 한다.\n만일 서버 방화벽에서 허용되어 있다면 외부에서도 noVnc로 접근할 수 있다. (!!)\n사실 실제 그렇게 되어있을 가능성은 희박하지만, 그래도 안전하게 해서 나쁠 건 없다.\n다음의 예시처럼 패스워드 설정을 확실하게 해두도록 하자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 // .devcontainer/devcontainer.json { // ... \u0026#34;features\u0026#34;: { // https://github.com/devcontainers/features/tree/main/src/desktop-lite \u0026#34;ghcr.io/devcontainers/features/desktop-lite:1\u0026#34;: { \u0026#34;password\u0026#34;: \u0026#34;my-extremely-complex-password\u0026#34; }, } // ... } Git 저장소에 패스워드가 올라가는 게 꺼려진다면 다음과 같이 설정해도 된다.\n1 2 # .devcontainer/.env NOVNC_PASSWORD=my-extremely-complex-password 1 2 3 4 5 6 7 8 9 10 11 12 13 // .devcontainer/devcontainer.json { // ... \u0026#34;features\u0026#34;: { // https://github.com/devcontainers/features/tree/main/src/desktop-lite \u0026#34;ghcr.io/devcontainers/features/desktop-lite:1\u0026#34;: { \u0026#34;password\u0026#34;: \u0026#34;${localEnv:NOVNC_PASSWORD}\u0026#34; }, } // ... } .env 파일은 .gitignore에 추가 해두도록 하자.\n마치며 이번 포스트에서는 DevContainer에서 GUI 프로그램을 실행할 때 겪는 문제와 그 해결책에 대해 다뤄봤다.\n처음에는 단순히 Docker의 무거움 때문에 원격 개발 서버를 구축했는데,\n그 과정에서 크롤링 작업을 할 때 GUI를 볼 수 없다는 새로운 문제에 직면하게 되었다.\n다행히 DevContainer의 Features 시스템 덕분에 Desktop Lite를 통해 이 문제를 깔끔하게 해결할 수 있었다.\nnoVNC를 통해 웹 브라우저로 DevContainer 내부의 GUI에 접근할 수 있게 된 것이다.\n이제 원격 개발 서버의 장점을 그대로 유지하면서도,\n필요할 때는 GUI 프로그램을 시각적으로 확인하며 개발할 수 있게 되었다.\n이번에는 사람도 편하고 기계도 편한 해결책을 찾은 것 같다 😊\n당장 마땅한 GUI 프로그램이 없어서 크롤링을 예시로 들었다.\n예상컨데 이외에도 활용할 수 있는 여지는 많을 것으로 보인다.\n혹시나 비슷한 고민을 겪고 있는 분들이 있었다면 부디 도움이 되었길 바란다.\n참고자료 Dev Containers Features Spec – desktop-lite VS Code Remote Containers Documentation noVNC project site ","date":"2025-10-19T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/desktop-lite-devcontainer-feature/images/cover_hu_8c99a18bec5d5fdd.png","permalink":"https://blog.ayteneve93.com/p/dev/desktop-lite-devcontainer-feature/","title":"DevContainer Desktop Lite Feature"},{"content":"들어가기 앞서 회사의 서버 인프라를 관리하는 한 사람의 엔지니어로써\n개인적으로 정말 듣기 싫어하는 소리가 하나 있다.\n제 컴퓨터에선 잘 돌아가던데요?\n개발자 PC에선 잘 동작하던 코드가 서버에 올리기만 하면 온갖 에러를 내면서 뻗어버리는 경우가 있는데, 이는 근본적으로 저마다 서로 다른 개발 및 테스트 환경을 구축해 두었기 때문에 생기는 문제이다.\n예를들어:\n운영체제 개발도구(IDE 등) 환경 설정 (dev/prod/test/stage) 등등\u0026hellip;\n더 볼 것도 없이 애초에 작업은 Windows나 Mac에서 하면서 그걸 실행하는 서버가 Linux이니 문제가 안 생길래야 안 생길 수가 없다.\n오죽하면 서버실에서 제단 쌓고 기도까지 올릴 지경이다 다행히 Docker와 Container를 도입한 이래로는 이러한 일의 발생 빈도가 확연히 줄어들었다.\n환경변수 설정을 잘못 잡아놨다거나, 이미지를 빌드할 때 CPU Architecture(ARM/AMD)를 고려하지 않았다 수준의 특이 케이스를 제외하면 말이다.\n작업한 소스코드를 테스트 할 때, 빌드한 소스가 서버에서 실행 될 때 모두 Container 위에서 동작하는 것이므로 일관성이 보장된다. 👍\n문제의 시작 개발환경 구축의 어려움 그런데 정작 개발환경 그 자체는 어떠한가?\n개발자의 로컬 PC에서 이미지를 빌드하고 컨테이너로 실행시켜 테스트 해보는 건 어렵지 않다.\n하지만 여전히 개발자의 작업 환경은 Windows 혹은 Mac이라는 OS에 종속되어 있다.\n이러한 OS에 Native하게 환경을 구축하는 것은 여전히 매우 번거로운 일이다.\nWindows에 Java Spring Boot + Oracle DB를 쓰는 웹 애플리케이션 개발환경을 구축한다고 생각해보자.\n우선 Java부터 설치해야 한다.\nOracle에 접속해 원하는 버전의 JDK를 다운\n1에서 다운받은 파일을 실행시켜 설치 진행\nJDK의 설치 경로를 확인, 가령 C:\\Program Files\\Java\\jdk-x.y.z\nWindows 환경 변수에 JAVA_HOME 시스템 변수를 생성하고 3의 경로를 입력\n시스템 변수 Path에 4에서 입력한 환경변수를 추가, 가령 %JAVA_HOME%\\bin\n이 정도는 별 거 아니라고 생각할 수도 있다.\n같은 스택의 새 프로젝트를 맡게 되었다.\n근데 사용하는 Java 버전이 달라졌다고 한다.\n어떻게 하지?\n새로운 JDK를 설치해야할까? 아니면 IntelliJ IDEA같은 IDE로 넘어가야 하나?\n연결하는 데이터베이스 버전도 바뀐다면?\nOracle DB 19C를 사용해서 프로젝트를 진행했었는데,\n새 프로젝트에선 Oracle DB 21C를 사용한다고 한다.\n로컬 PC에 그럼 DB를 2개 설치해야 하는 걸까? 기존 DB를 지워야 하나?\n아니면 아예 DB 서버에 개발용 Schema를 따로 만들어야 하나?\n다 떠나서 아예 컴퓨터를 새로 사거나 포맷이라도 하면\u0026hellip;?\n\u0026hellip;이건 정말 상상하기도 싫다.\n그나마 고유의 환경 구축 절차를 별도로 문서화 해두지 않았다면 감히 엄두도 못 낼 일이다.\n여태 언급된 것들은 결코 허황되거나 과장된 예시가 아니다.\n(왜냐하면 내가 지금 다니고 있는 회사가 처음에 딱 저런 상황이었으니까\u0026hellip;)\nJava Spring Boot에 Oracle DB\n이는 대한민국에서 한 때 (어쩌면 지금도) 가장 보편적으로 쓰이던 애플리케이션 스택이다.\n이렇게 단순한 예시에서도 문제점이 훤히 보이는데, 실제 개발 환경은 저것보다 훨씬 더 복잡하고 다양한 의존성을 요구하기 마련이다.\nDevContainer 기본설정 Codespaces, Gitpod, DevPod등 여러가지 방법론이 제시되어 왔으나,\n대개 별도 플랫폼에 종속된 서비스의 형태(SaaS)로 제공되거나 (당연히 무료는 아니다)\n자체 호스팅을 하더라고 Kubernetes 클러스터가 전제 조건인 경우도 있다.\n처음부터 이렇게 밑밥을 깔아버리면 도전하고자 하는 마음이 꺾일 지도 모른다.\n고로 이번 포스트에선 개발자에게 가장 익숙하고 직관적인 방식으로 접근하고자 한다.\n우선 앞서 기술한 문제의 요지를 정리하면 다음과 같다.\n개발환경의 일관성 보장 X\n각 프로젝트별로 격리된 환경 보장 X\n인간의 실수가 발생할 여지가 많은 복잡한 구성 절차\n그런데 이거\u0026hellip; 어디서 많이 봐왔던 이슈 아닌가?\n그렇다, 우리가 Docker를 사용하는 이유와 일맥상통한다.\n테스트 환경과 운영 환경을 Container로 일관화 시켰듯,\n개발환경도 Container화 해보도록 하자.\n이번에 알아볼 주제는 DevContainer이다.\nDevContainer란 무엇인가? DevContainer (Development Container)는 Microsoft에서 만든 오픈소스 프로젝트로,\nvsCode 혹은 Cursor와 같은 vsCode의 파생 IDE 에서 사용할 수 있는 개발 전용 라이브러리이다.\n개발에 필요한 모든 도구, 모든 설정을 Container로 패키징하여 사용할 수 있게 해준다.\n주요 특징은 다음과 같다.\n일관성\n사용자가 PC를 포맷을 하든, 새로운 PC를 구매하든 관계없다.\n심지어 어떤 OS를 사용해도 항상 동일한 환경을 보장한다.\n재현성\n환경 구축 자체를 Docker를 사용해서 하므로,\n복잡한 과정 없이 단 한 번의 클릭으로 모든 개발환경이 완벽하게 세팅된다.\n격리성\n프로젝트별로 고유한 개발 환경을 구축할 수 있다.\n사전준비 Docker 실행환경\n당연한 얘기지만 Docker에 접근 할 수 있어야 한다.\nWindows 사용자라면 WSL과 Docker Desktop이 필요하고\nMac 사용자라면 Docker Desktop을 설치하도록 하자.\n만일 별도의 Linux 개발서버를 사용한다면 해당 서버에 Docker와 Docker Compose를 설치하고 SSH로 접속 가능하도록 설정해주자.\n이 내용을 다 적었다간 지나치게 장황해지므로 추후 별도로 포스트 하도록 하겠다.\nvsCode 혹은 Cursor 설치\nIDE에 Extension 설치\nvsCode 사용자라면 Remote Development으로 한 번에 필요한 모든 Extension을 설치할 수 있다.\nCursor 사용자라면\n우선 Dev Container Extension이 필요하다.\n추가로 WSL을 사용하는 경우는 WSL Extension을\nSSH로 원격 개발 서버를 통해서 사용한다면 Remote - SSH Extension을 설치해야 한다.\n설정 파일 준비 DevContainer 디렉토리 DevContainer 설정 파일들을 저장하기 위해 프로젝트 루트에 .devcontainer라는 디렉토리를 만들어주자.\ndevcontainer 구성을 위한 모든 파일은 전부 이 디렉토리 안에 저장된다.\ndevcontainer.json DevContainer 빌드의 시작점이 되는 파일이다.\n이 파일의 전체적인 Spec이 궁금하다면 다음의 링크에서 확인 해보자.\n사용할 수 있는 옵션이 굉장히 많은데,\n우선은 최대한 간단하게 예시와 같이 작성해주자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // .devcontainer/devcontainer.json { // General // Docker Desktop을 쓴다면 UI에 다음의 속성이 표시된다. \u0026#34;name\u0026#34;: \u0026#34;My First DevContainer\u0026#34;, // Container 내부에서 사용할 사용자명 // 특별한 이유가 없다면 그냥 `vscode`로 두자 \u0026#34;remoteUser\u0026#34;: \u0026#34;vscode\u0026#34;, // Docker Compose // Docker Image로 설정하는 방식도 있는데 // 멀티 컨테이너로의 확장성을 고려하면 docker compose를 사용하는 걸 추천한다. // 대상 Docker Compose 파일 \u0026#34;dockerComposeFile\u0026#34;: \u0026#34;docker-compose.dev.yml\u0026#34;, // compose 파일에서 DevContainer로 동작시킬 service의 이름이다 \u0026#34;service\u0026#34;: \u0026#34;workspace\u0026#34;, // devcontainer로 동작시킬 대상 service에서 프로젝트가 위치할 경로이다. \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/workspace/my-project-name\u0026#34;, } docker-compose.dev.yml 위에서 지정한 dockerComposeFile이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # .devcontainer/docker-compose.dev.yml services: # `devcontainer.json`에서 service를 \u0026#34;workspace\u0026#34;라고 # 지정했으므로 여기에도 같은 이름을 적어야한다. workspace: container_name: my-devcontainer-workspace # Base on Ubuntu Noble image: mcr.microsoft.com/devcontainers/base:dev-noble command: sleep infinity volumes: # Workspace Cache # `devcontainer.json`의 `workspaceFolder`와 동일한 값이어야 한다. - ..:/home/vscode/workspace/my-project-name:cached 여기까지 진행했다면 기본적인 준비는 끝이 났다. 🎉\nF1 키를 누르고 Dev Containers: Rebuild Container를 실행하자.\n간단히 컨테이너가 빌드 / 실행되고 내부로 IDE가 진입하는 모습이다.\n자 이제 하나씩 심화 과정으로 들어가보자.\n심화 과정 Image는 뭘 고르지? 예시에서 사용한 Docker Image는 mcr.microsoft.com/devcontainers/base:dev-noble이다.\n이 이미지에 대해 간단히 설명하면 다음과 같다.\nmcr (Microsoft Artifact Registry 혹은 Microsoft Container Registry)에 배포된\nMicrosoft가 만든\nDevContainer용\nbase(기반) 이미지의\ndev-noble 태그 (Ubuntu Noble 버전)\n당연히 다른 이미지도 엄청나게 많다.\n내가 고른 건 여기서 가장 오른쪽 아래 이미지이다 base image는 말 그대로 기반이 되는 이미지로, 안에는 Git이나 Bash 같은 걸 제외하고는 아무것도 설치되어 있지 않다.\n원하는 개발 환경에 맞춰 이미지를 고르면 된다.\n예를들어:\nTypescript를 써서 Node.Js 앱 만들어야지 .NET 프레임워크 기반으로 앱 만들고싶어 모르겠고, 그냥 모든 언어가 다 설치되어 있으면 좋겠어 하지만 나는 개인적으로는 base 이미지를 고수하는 걸 추천한다.\n어? 그럼 개발도구 설치는 어떻게 하지?\nDockerfile을 커스텀 해서 써야하나?\n지극히 당연한 추론이다.\n하지만 그보다 더 좋은 방법이 있다.\n다음 섹션을 참고하자.\nFeatures로 개발도구 설치하기 DevContainer에는 Features라는 항목이 있다.\ndevcontainer.json파일에 원하는 Feature(기능)를 선언하면,\n해당하는 기능들이 DevContainer에 설치된다.\n사용 가능한 기능들은 DevContainer 페이지의 Available Features 탭에서 확인 가능하다.\n설치 말해 뭐 하겠는가, 직접 해보도록 하자.\n예시로 Java, nodejs, python 그리고 Docker in Docker를 DevContainer에 설치해보겠다.\n.devcontainer/devcontainer.json 파일을 다음과 같이 수정하자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // .devcontainer/devcontainer.json { // General \u0026#34;name\u0026#34;: \u0026#34;My First DevContainer\u0026#34;, \u0026#34;remoteUser\u0026#34;: \u0026#34;vscode\u0026#34;, // Docker Compose \u0026#34;dockerComposeFile\u0026#34;: \u0026#34;docker-compose.dev.yml\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;workspace\u0026#34;, \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/workspace/my-project-name\u0026#34;, // ------ 위는 기존 설정 그대로이다 ------ // // Features \u0026#34;features\u0026#34;: { // Java 설치 // https://github.com/devcontainers/features/tree/main/src/java \u0026#34;ghcr.io/devcontainers/features/java:1\u0026#34;: {}, // Node.js 설치 // https://github.com/devcontainers/features/tree/main/src/node \u0026#34;ghcr.io/devcontainers/features/node:1\u0026#34;: {}, // Python 설치 // https://github.com/devcontainers/features/tree/main/src/python \u0026#34;ghcr.io/devcontainers/features/python:1\u0026#34;: {}, // Docker in Docker 설치 // https://github.com/devcontainers/features/tree/main/src/docker-in-docker \u0026#34;ghcr.io/devcontainers/features/docker-in-docker:2\u0026#34;: {} } } 이후 다시 F1 키를 누르고 Dev Containers: Rebuild Container를 실행하자.\n저것들 다 설치하려면 제법 시간이 걸린다.\n커피 한 잔 끓여 오도록 하자.\n설치 확인 DevContainer가 Rebuild 되었다면 터미널을 열고 제대로 설치가 되었는지 확인해보자.\nJava\n1 2 3 4 vscode ➜ ~/workspace/my-project-name $ java --version openjdk 25 2025-09-16 LTS OpenJDK Runtime Environment Microsoft-12398176 (build 25+36-LTS) OpenJDK 64-Bit Server VM Microsoft-12398176 (build 25+36-LTS, mixed mode, sharing) nodejs\n1 2 vscode ➜ ~/workspace/my-project-name $ node --version v22.20.0 python\n1 2 vscode ➜ ~/workspace/my-project-name $ python --version Python 3.12.3 Docker in Docker (Host와 격리된 Docker 환경)\n1 2 vscode ➜ ~/workspace/my-project-name $ docker --version Docker version 28.5.1-1, build e180ab8ab82d22b7895a3e6e110cf6dd5c45f1d7 모든 개발 도구들이 성공적으로 잘 설치되었다.\n주석을 제외하면 추가한 내용은 고작 4줄밖에 되지 않는다.\n어디서 뭘 받고, 하나 하나 다음(Next) 눌러주면서 설치 해주고, 환경변수 설정하고\u0026hellip;\n이런 고생은 이제 안녕이다.\n(덕분에 온보딩 기간에 신입 직원이 개발환경 구성한답시고 일주일 날로 먹는 혜택은 누리기 힘들어지겠지만)\nDevContainer를 쓰면 Features를 통해 원하는 환경을 입맛에 맞춰 아주 간편하게 구성할 수 있다.\n당연히 이는 모두 Container 안에 설치되는 것이므로, Host PC 환경에 영향을 줄 일도, 다른 container에 영향을 줄 일도 없다.\n상세 설정 Feature에는 상세 옵션도 설정 가능하다.\n마침 Java를 예시로 들었으니 내친김에 JDK 버전을 한 번 변경해보자.\ndevcontainer.json의 주석에도 적어둔 Java Feature Repository 링크로 들어가면 보다 상세한 설정 정보가 나온다.\nOptions Id Description Type Default Value version Select or enter a Java version to install string latest additionalVersions Enter additional Java versions, separated by commas. string - jdkDistro Select or enter a JDK distribution string ms installGradle Install Gradle, a build automation tool for multi-language software development boolean false gradleVersion Select or enter a Gradle version string latest installMaven Install Maven, a management tool for Java boolean false mavenVersion Select or enter a Maven version string latest installAnt Install Ant, a software tool for automating software build processes boolean false antVersion Select or enter an Ant version string latest installGroovy Install Groovy, powerful, optionally typed and dynamic language with static-typing and static compilation capabilities boolean false groovyVersion Select or enter a Groovy version string latest 이 표는 DevContainer Java Feature에서 사용 가능한 옵션들이다.\n특별히 명시를 안 해주면 기본값(Default Value)이 적용된다.\nJava 버전을 정하는 version 옵션의 기본값은 latest다.\n실제로 확인해보면 작성일 기준, JDK 25가 설치되어 있다.\n이를 21로 변경해보자.\n1 2 3 4 5 6 7 8 9 10 11 // .devcontainer/devcontainer.json { // ------ 기존 설정 ------ // \u0026#34;features\u0026#34;: { // Java Feature // https://github.com/devcontainers/features/tree/main/src/java \u0026#34;ghcr.io/devcontainers/features/java:1\u0026#34;: { \u0026#34;version\u0026#34;: \u0026#34;21\u0026#34; // Java 21 버전으로 변경 } } } 다시 F1 키를 누르고 Dev Containers: Rebuild Container를 실행하자.\nDocker가 작업을 마치고 나면 터미널을 열고 Java 버전을 확인해보자.\n1 2 3 4 vscode ➜ ~/workspace/my-project-name $ java --version openjdk 21.0.8 2025-07-15 LTS OpenJDK Runtime Environment Microsoft-11933203 (build 21.0.8+9-LTS) OpenJDK 64-Bit Server VM Microsoft-11933203 (build 21.0.8+9-LTS, mixed mode, sharing) openjdk 21.0.8이 출력되었다. 잘 적용된 모습이다.\nCustomizations 설정 DevContainer에는 Customizations라는 항목도 있다.\n이는 해당 DevContainer만을 위한 IDE(vsCode) 커스텀 설정을 구성한다.\nsettings\nvsCode의 settings.json의 기본값을 설정해준다.\nextensions\n지정한 Extension을 container에 설치한다.\nSettings vsCode 설정을 프로젝트별로 다르게 하고 싶을 때는 보통\n루트 경로의 .vscode/settings.json 파일을 만들어 사용하는 것이 일반적이다. (개인적으로 이 방식을 추천한다)\ndevcontainer.json에 지정하는 custom settings는 이와 유사하게 동작한다.\nIDE 화면이 맨날 새까만 화면이라 지루하지 않았는가?\n간단한 예시로 IDE의 테마를 한 번 변경해보자.\n가장 하단의 customizations을 보면 된다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // .devcontainer/devcontainer.json { // General \u0026#34;name\u0026#34;: \u0026#34;My First DevContainer\u0026#34;, \u0026#34;remoteUser\u0026#34;: \u0026#34;vscode\u0026#34;, // Docker Compose \u0026#34;dockerComposeFile\u0026#34;: \u0026#34;docker-compose.dev.yml\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;workspace\u0026#34;, \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/workspace/my-project-name\u0026#34;, // Features \u0026#34;features\u0026#34;: { // Java Feature // https://github.com/devcontainers/features/tree/main/src/java \u0026#34;ghcr.io/devcontainers/features/java:1\u0026#34;: { \u0026#34;version\u0026#34;: \u0026#34;21\u0026#34; // Java 21 버전으로 변경 }, // Node.js Feature // https://github.com/devcontainers/features/tree/main/src/node \u0026#34;ghcr.io/devcontainers/features/node:1\u0026#34;: {}, // Python Feature // https://github.com/devcontainers/features/tree/main/src/python \u0026#34;ghcr.io/devcontainers/features/python:1\u0026#34;: {}, // Docker in Docker Feature // https://github.com/devcontainers/features/tree/main/src/docker-in-docker \u0026#34;ghcr.io/devcontainers/features/docker-in-docker:2\u0026#34;: {} }, // Custom \u0026#34;customizations\u0026#34;: { \u0026#34;vscode\u0026#34;: { // ------ 위는 기존 설정 그대로이다 ------ // // Settings \u0026#34;settings\u0026#34;: { \u0026#34;workbench.colorTheme\u0026#34;: \u0026#34;Red\u0026#34; }, } } } F1 키를 누르고 Dev Containers: Rebuild Container를 실행한다.\n당신의 눈 건강을 위해 준비한 붉은색 테마이다 내 눈은 아직 건강하므로 바로 원상복귀 해주었다. Extensions 아무런 HTML 파일이나 하나 만들어보자.\n내 경우 Cursor에게 자기 자신에 대해 소개하는 HTML을 생성하라고 해보았다.\n갑자기 이게 무슨 뚱딴지같은 소린가 싶겠지만 일단 계속 들어보길 바란다.\nDevContainer에서 html 파일을 열어보면 다음과 같이 나올 것이다.\n평범한 Editor 화면이 나온다.\nhtml 미리보기가 가능하다면 좋겠는데 어떻게 할까?\nExtension Marketplace에서 List Preview라고 검색해서 Extension을 설치해주면 된다.\n위 이미지 우측 하단에 있는 마켓플레이스 블록의 식별자를 살펴보자.\nms-vscode.live-server라고 적혀있는 걸 볼 수 있다.\n이는 이 Extension의 고유 ID이다. 이제 이 값을 devcontainer.json에 기입해 자동으로 설치되게 해보자.\n가장 하단에 있는 customizations 블록을 보면 된다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // .devcontainer/devcontainer.json { // General \u0026#34;name\u0026#34;: \u0026#34;My First DevContainer\u0026#34;, \u0026#34;remoteUser\u0026#34;: \u0026#34;vscode\u0026#34;, // Docker Compose \u0026#34;dockerComposeFile\u0026#34;: \u0026#34;docker-compose.dev.yml\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;workspace\u0026#34;, \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/workspace/my-project-name\u0026#34;, // Features \u0026#34;features\u0026#34;: { \u0026#34;ghcr.io/devcontainers/features/java:1\u0026#34;: { \u0026#34;version\u0026#34;: \u0026#34;21\u0026#34; }, \u0026#34;ghcr.io/devcontainers/features/node:1\u0026#34;: {}, \u0026#34;ghcr.io/devcontainers/features/python:1\u0026#34;: {}, \u0026#34;ghcr.io/devcontainers/features/docker-in-docker:2\u0026#34;: {} }, // ------ 위는 기존 설정 그대로이다 ------ // // Customizations \u0026#34;customizations\u0026#34;: { \u0026#34;vscode\u0026#34;: { // Extensions \u0026#34;extensions\u0026#34;: [ // HTML Viewer Extension \u0026#34;ms-vscode.live-server\u0026#34; ] } } } 한 번 더 F1 키를 누르고 Dev Containers: Rebuild Container를 실행한다.\n이후 HTML 파일을 다시 열어보면 오른쪽 상단에 미리보기 버튼이 활성화된 것을 볼 수 있다.\n이 버튼을 누르면 HTML 미리보기가 가능하다.\nLifecycle Scripts DevContainer는 일정한 생명주기(lifecycle)를 가지고 있다.\n그리고 각 생명주기에서 실행될 별도의 Script를 지정해줄 수 있는데 이를 Lifecycle Scripts라고 한다.\n순서는 다음과 같다.\ninitializeCommand\nDevContainer가 생성되기 전에\n호스트에서 실행 (이 이후 커맨드는 전부 Container 내부에서 실행된다.)\nonCreateCommand\nDevContainer가 생성된 직후 실행\nupdateContentCommand\nDevContainer의 내용이 업데이트될 때 실행\npostCreateCommand\nDevContainer 생성 완료 후 실행\npostStartCommand\nDevContainer가 시작(start)될 때마다 실행\npostAttachCommand\nVS Code나 Cursor등 IDE가 DevContainer에 연결될 때 실행\nwaitFor\n특정 조건이 만족될 때까지 대기\n예: 데이터베이스가 준비될 때까지, 특정 포트가 열릴 때까지 대기\n대부분의 도구는 Features로 설치가 가능하지만,\n마이너한 혹은 회사에서만 사용하는 독특한 프로그램이 있다면, Lifecycle Scripts를 사용해 구현이 가능하다.\n이번엔 Lifecycle Scripts 중 하나인 updateContentCommand를 이용해서 DevContainer에 redis-cli라는 프로그램을 설치해보자.\n우선 .devcontainer 밑에 lifecycle이라는 디렉토리를 만들어주자.\n이후 updateContentCommand.sh 파일을 다음과 같이 작성해준다.\n1 2 3 4 5 6 7 8 9 10 #!/usr/bin/env bash # .devcontainer/lifecycle/updateContentCommand.sh echo \u0026#34;🔄 Updating apt package manager\u0026#34; sudo apt update -y sudo apt upgrade -y echo \u0026#34;🔄 Installing apt packages\u0026#34; sudo apt install -y \\ redis-tools devcontainer.json 파일에 LifeCycle Scripts를 추가한다.\n가장 하단을 보면 된다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 // .devcontainer/devcontainer.json { // General \u0026#34;name\u0026#34;: \u0026#34;My First DevContainer\u0026#34;, \u0026#34;remoteUser\u0026#34;: \u0026#34;vscode\u0026#34;, // Docker Compose \u0026#34;dockerComposeFile\u0026#34;: \u0026#34;docker-compose.dev.yml\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;workspace\u0026#34;, \u0026#34;workspaceFolder\u0026#34;: \u0026#34;/home/vscode/workspace/my-project-name\u0026#34;, // Features \u0026#34;features\u0026#34;: { // Java Feature // https://github.com/devcontainers/features/tree/main/src/java \u0026#34;ghcr.io/devcontainers/features/java:1\u0026#34;: { \u0026#34;version\u0026#34;: \u0026#34;21\u0026#34; // Java 21 버전으로 변경 }, // Node.js Feature // https://github.com/devcontainers/features/tree/main/src/node \u0026#34;ghcr.io/devcontainers/features/node:1\u0026#34;: {}, // Python Feature // https://github.com/devcontainers/features/tree/main/src/python \u0026#34;ghcr.io/devcontainers/features/python:1\u0026#34;: {}, // Docker in Docker Feature // https://github.com/devcontainers/features/tree/main/src/docker-in-docker \u0026#34;ghcr.io/devcontainers/features/docker-in-docker:2\u0026#34;: {} }, // Custom \u0026#34;customizations\u0026#34;: { \u0026#34;vscode\u0026#34;: { // Extensions \u0026#34;extensions\u0026#34;: [ // HTML Viewer Extension \u0026#34;ms-vscode.live-server\u0026#34; ] } }, // ------ 위는 기존 설정 그대로이다 ------ // // LifeCycle Scripts \u0026#34;updateContentCommand\u0026#34;: \u0026#34;bash ./.devcontainer/lifecycle/updateContentCommand.sh\u0026#34;, } F1 키를 누르고 Dev Containers: Rebuild Container를 실행한다.\n컨테이너가 Rebuild 되고 나면 터미널을 열어 제대로 설치가 되어있는지 확인해보자.\n1 2 vscode ➜ ~/workspace/my-project-name $ redis-cli --version redis-cli 7.0.15 이렇게 버전이 출력되면 잘 설치 된 것이다.\n멀티 컨테이너 구성하기 작업하는 프로젝트에 외부 의존성이 존재한다고 해보자.\n예를들어:\nElasticSearch\nPostgreSQL\nRedis\n등등\u0026hellip;\n이것들은 단순히 \u0026ldquo;개발에 필요한 도구\u0026ldquo;라기 보단 \u0026ldquo;개발 환경\u0026rdquo; 내지는 \u0026ldquo;개발 인프라\u0026ldquo;에 더 가깝다.\nDevContainer에 직접 설치 할 수도 있지만 그다지 추천하는 방법은 아니다.\n지금까지의 예시에서 사용한 DevContainer는 docker-compose.dev.yml 파일을 사용하고 있다.\n당연히 일반적인 docker compose처럼 다른 service(container)를 등록하는 것도 가능하다.\n외부 의존성은 이 docker compose 파일에 정의 해줌으로써 간편하게 설정 할 수 있다.\n예시로 Redis Container를 docker-compose.dev.yml에 추가해보자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # .devcontainer/docker-compose.dev.yml services: workspace: container_name: my-devcontainer-workspace image: mcr.microsoft.com/devcontainers/base:dev-noble command: sleep infinity volumes: # Workspace Cache - ..:/home/vscode/workspace/my-project-name:cached # ----- 위는 기존 설정 그대로이다 ----- # redis: container_name: my-devcontainer-redis image: redis:alpine volumes: - my-project-name-redis:/data command: redis-server --save 20 1 --loglevel warning --requirepass my-redis-password volumes: my-project-name-redis: F1 키를 누르고 Dev Containers: Rebuild Container를 실행한다.\nRebuild가 되면 터미널을 열고 redis-cli 명령어로 새롭게 추가된 Redis 컨테이너에 접속해보자.\n바로 위 Lifecycle Scripts 섹션에 설치 방법을 적어두었다.\n명령어는 다음과 같이 구성되어 있다.\nredis-cli -u redis://\u0026lt;사용자명\u0026gt;:\u0026lt;비밀번호\u0026gt;@\u0026lt;접속 주소\u0026gt;:\u0026lt;포트\u0026gt;\n위 docker-compose.dev.yml에서는 별도 사용자명은 지정하지 않았고\n비밀번호는 my-redis-password로 해두었다.\n포트는 기본값을 그대로 쓰므로 굳이 지정하지 않아도 된다.\nURL은 docker compose의 service명을 그대로 쓴다.\n따라서 다음과 같이 입력한다.\nredis-cli -u redis://default:my-redis-password@redis\n1 2 3 vscode ➜ ~/workspace/my-project-name $ redis-cli -u redis://default:my-redis-password@redis Warning: Using a password with \u0026#39;-a\u0026#39; or \u0026#39;-u\u0026#39; option on the command line interface may not be safe. redis:6379\u0026gt; 이와 같은 터미널 결과를 봤다면 정상적으로 연결이 된 것이다.\n마치며 이번 포스트에서는 DevContainer를 통해 개발환경의 일관성과 격리성을 확보하는 방법에 대해 알아보았다.\n처음에 언급했던 \u0026ldquo;제 컴퓨터에선 잘 돌아가던데요?\u0026ldquo;라는 말을 들을 일이 이제는 훨씬 줄어들 것이다.\nDevContainer를 사용하면:\n개발환경의 일관성이 보장되고 프로젝트별 격리된 환경을 구축할 수 있으며 복잡한 수동 설정 과정을 자동화할 수 있다 특히 Features를 통한 개발도구 설치, Customizations을 통한 IDE 설정, Lifecycle Scripts를 통한 커스텀 스크립트 실행 등은 정말 강력한 기능들이다.\n물론 처음 설정할 때는 조금 번거로울 수 있다.\n하지만 한 번 구축해두면 팀원 누구나 동일한 환경에서 개발을 시작할 수 있고,\n새로운 PC를 구매하거나 포맷을 해도 개발환경 구축에 대한 걱정은 이제 그만이다.\n더 나아가 멀티 컨테이너 구성을 통해 데이터베이스나 캐시 서버 같은 외부 의존성까지도 함께 관리할 수 있다는 점은 정말 매력적이다.\n추후 계획 현재 DevContainer를 통해 로컬 개발환경의 일관성을 확보했지만, 더 나아가 클라우드 기반의 개발환경을 구축하는 것도 고려해볼 만하다.\nDevPod는 DevContainer 설정을 거의 그대로 사용할 수 있는 오픈소스 도구로, 다양한 클라우드 프로바이더를 지원한다. 특히 Kubernetes 클러스터를 프로바이더로 설정하면 다음과 같은 장점을 얻을 수 있다:\n확장성: 필요에 따라 개발환경의 리소스를 동적으로 조정 격리성: 각 개발자마다 완전히 독립된 네임스페이스에서 작업 중앙 관리: 모든 개발환경을 Kubernetes 클러스터에서 통합 관리 비용 효율성: 사용하지 않는 개발환경은 자동으로 종료하여 리소스 절약 DevContainer에서 작성한 .devcontainer/devcontainer.json 설정을 DevPod에서도 거의 그대로 활용할 수 있으므로, 기존 설정을 최대한 재사용하면서 클라우드 환경으로 확장할 수 있다.\n이를 통해 팀 전체가 동일한 클라우드 환경에서 개발할 수 있는 인프라를 구축할 계획이다.\n참고 자료\nDevContainer 공식 문서 Available Features devcontainer.json 스펙 ","date":"2025-10-18T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/what-is-devcontainer/images/cover_hu_67eca9528f87076f.png","permalink":"https://blog.ayteneve93.com/p/dev/what-is-devcontainer/","title":"DevContainer 톺아보기"},{"content":"기나긴 추석 연휴가 지나고 나니 엄청난 후유증이 몰려왔다\n추적추적\u0026hellip; 끝도 없이 쏟아지는 비에 몸까지 천근만근이다.\n도무지 출근길에 적응이 되질 않는다.\n그래서 휴가를 냈다! 그렇다. 오늘은 쉬는 날이다. 🤗\n회사 메신져에서 들려오는 직원들의 곡소리를 자장가 삼아\n12시까지 정말이지 신나게 늦잠을 잤다.\n나른한 아침(낮)이 지나간다.\n가볍게 기지개를 편다.\n오늘은 하고싶은 일을 해야겠다.\n향긋한 커피, 함께 먹을 달달한 간식까지 준비해 책상 앞에 앉는다.\n게임하게? 아님 영화?\n물론 둘 다 너무나 좋아하는 취미생활이지만\n오늘 내가 할 건 k8s 클러스터에 올릴 새로운 프로젝트 구성이다.\n진정한 개발자(변태)라면 이런 날,\n평소 개인적으로 흥미를 가지고 있던 주제에 몰두하며\n하루종일 컴퓨터와 씨름하는 것이야 말로 최고의 힐링이지 않겠는가 들어가기 앞서 내가 k8s 클러스터를 관리하는 프로젝트는 기본적으로 개발 컨테이너(devContainer) 위에서 동작하는 Terraform 프로젝트이다.\n다만 일반적인 Terraform 프로젝트와는 몇 가지 다른 점이 있는데:\nHCL을 사용하지 않는다.\nCDK for Terraform을 사용해서 개발하고 있기 때문이다.\n간단하게 말 하면, Java나 C#, Python같은 익숙한 프로그래밍 언어로 HCL를 대신하는 프로젝트이다.\n내 경우 Typescript로 작업한다.\nNest.js 프레임워크를 적용했다.\n당연히 일반적인 nodejs에서 사용하는 프레임워크 적용도 가능하다.\n프로젝트 관리 도구 \u0026lsquo;Projen\u0026rsquo;을 사용한다.\nProjen? 이번 포스트는 카테고리부터가 Fool(바보짓, 삽질)이다. (아니면 하소연)\n그래서 정보성 내용은 최대한 간결하게 맛보기 정도로만 쓰고, 추후 기회가 되면 더 자세하게 쓰도록 하겠다.\n그래도 밑밥은 깔고 가야 하므로-\nProjen은 소프트웨어 프로젝트 관리를 위한 프로젝트, 일명 CDK for Project이다.\nProjen Github 프로젝트에 보면 설명이 잘 되어 있지만, 경험에 비추어 간략하게 설명 해보겠다.\n설정 파일의 파편화 프로젝트 루트 트리 위 사진은 앞서 기술한 IaC 프로젝트의 루트 디렉토리이다.\n루트 경로에서부터 벌써 오만가지 다양한 폴더와 파일들이 있는 걸 볼 수 있다.\n이는 비단 어떤 형태의 프로젝트이든 상황은 매한가지일 것이다.\n이거 다 필요한 파일인가?\n당연히 다 필요하다.\n예를 들어\n.gitignore는 git tracking에서 제외할 파일이나 폴더들을 기술한다.\n요컨대, 원치 않는 파일들이 GitHub나 GitLab, Bitbucket등에 업로드 되는 것을 필터링 하는 규칙 설정이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 폴더나 파일 .DS_STORE /.secrets /.kube /cdktf.out.json /env /keys /cdktf.out /scripts/generated /tmp # Glob 패턴 *.log *.pid *.seed *.pid.lock # 혹은 반대로 반드시 Tracking 하도록 명시 !/.gitattributes !/.projen/tasks.json !/.projen/deps.json !/.projen/files.json !/.github/workflows/pull-request-lint.yml !/package.json !/LICENSE 자동으로 생성되는 파일, 비밀 파일, 임시 파일등이 주로 기술된다.\n.github 디렉토리 밑에는 PR Template, Issue Template, Github Action Workflow등이 포함되어 있다.\nPR 제출시 제목에 `test`, `feat`, `fix` 이런식으로 앞에 붙여놔야지만 승인 되도록 하는 Workflow package.json은 nodejs 개발자라면 아주 익숙할 것이다.\n프로젝트 명, 스크립트 구성, 디펜더시 등이 기술 된다. 이렇듯 수많은 설정 파일들이 있다.\n모두 각자의 역할을 담당하며, 사용하는 키워드는 물론 표기방식(json, yaml, ini, toml 등)도 제각각이다.\n여기엔 크게 2가지의 문제가 있는데:\n학습 곡선이 가팔라진다.\nyaml? toml? 아니 개발자면 소스코드만 기가막히게 잘 짜면 됬지 이런걸 또 배우라고?\n이해가 되는 불만이지만, 그래도 배워야 한다.\n설정 파일이 여기저기 흩어져 있다.\n사실 이게 진짜 문제다.\n앞서 얘기했듯, 프로젝트를 구성하는 파일들은 모두 고유의 역할을 가지고 있다.\n하지만 동시에 서로 유기적으로 연동되는 경우도 많다.\n예를들어 프로젝트 루트의 logs 디렉토리에 애플리케이션 로그를 기록하고 싶다고 하자.\n그렇다면 애플리케이션의 환경변수에 로그 저장 경로는 logs라고 지정해줄 것이다.\n그리고 .gitignore에도 해당 경로를 입력해줘야 한다.\n같은 값을 2번 넣어주는 것이다.\n로그 저장 위치가 바뀐다면?\n그럴 일은 자주 일어나지 않겠지만, 이러면 위에서 한 작업, 가령 logs-v2로 바뀌었다면\n마찬가지로 환경변수와 .gitignore 모두에 logs-v2라는 값을 넣어줘야 한다.\n실수로 이중 하나를 제대로 처리 해주지 못 했다면 이제 github에 테스트하면서 생긴 로그 파일들이 잔뜩 올라가는 진풍경을 보게 될 것이다.\nSingle Source of Config Projen은 이런 수많은 파일들을 하나의 설정으로 정의 함으로써 생성 / 삭제 / 업데이트 하는 역할을 한다.\n일종의 Signle Source of Truth이다.\n이 프로젝트에선 .projenrc.ts라는 파일이 그것인데, .projenrc.java나 .projenrc.py도 가능하다.\n구체적인 코드 예시 하나를 들자면 다음과 같다.\n일주일에 1번씩 nodejs package를 최신화 하는 workflow 예시에서는 .projenrc.ts에 의존성 최신화 workflow를 사용 하겠다고 설정했다.\n업데이트 주기는 1주일로 명시 해뒀다.\n그 결과, .github/workflows/upgrade-develop.yml 파일이 생성된 모습이다.\n이 Workflow는 실제로 매우 잘 동작한다.\n의존성 최신화 동작 기록 위 사진에서 볼 수 있듯 1주일에 한번, 꼬박꼬박 잘 동작하는 모습이다.\n그리고\u0026hellip;\n오늘 터진 사건의 원흉이다\u0026hellip;\n발단 아직 부시시한 머리를 만지작 거리며 메인 PC의 전원을 킨다.\n화려한 바탕화면이 반겨준다.\n심호흡 한 번 하고-\n커피로 목을 축인다.\n공유기 서버로 접속해 WOL로 개발 서버 컴퓨터의 전원도 켜준다.\n서버가 켜질 때까지는 시간이 조금 필요하다.\n전 날 선물받은 초코 크루키를 한 입 베어문다.\n너무 달아. 이가 썩을 거 같아. 커피로 입가심을 한다.\nCursor IDE를 열고 SSH로 개발서버에 접속한다.\n프로젝트를 열고 Open in Container 버튼을 클릭한다.\nDocker가 요청받은 작업들을 실행한다.\n기반 이미지는 Ubuntu Noble, MS에서 배포하는 베이스 이미지다.\n그야말로 git 정도 외에는 아무것도 설치되어 있지 않아 마치 도화지 같다.\nDocker가 이 깡통 컨테이너에 지정한 Features와 Extension을 설치한다.\nPython, Projen, Github Cli, Terraform, Kubectl, Istio, Vault, DinD\u0026hellip;\n작업이 끝나면 GitHub에서 업데이트 된 코드를 받고 개발 의존성을 설치한다.\n이게 끝나면 projen을 통해 설정 파일들을 최신화 하고\nTerraform Provider가 각 Stack에 맞춰 다운로드 될 것이다.\n앗, 뭔가 잘못 되었다. ESLint 오류?\n눈을 비비며 발견한 로그를 복사해 Cursor에게 진단을 맡긴다.\nCursor의 진단이 끝나고, 조금 전 오류를 Reproduce하기 위해\nESLint를 터미널에서 실행해본다.\n역시 뭔가 잘못되었다.\n아무래도 오늘도 하루가 순탄치 않을 것 같다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ yarn eslint --fix yarn run v1.22.22 $ npx projen eslint --fix 👾 eslint | eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern --fix src scripts projenrc .projenrc.ts Oops! Something went wrong! :( ESLint: 9.37.0 ESLint couldn\u0026#39;t find a configuration file. To set up a configuration file for this project, please run: npm init @eslint/config@latest If you think you already have a configuration file or if you need more help, please stop by the ESLint Discord server: https://eslint.org/chat (node:14980) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag. (Use `node --trace-warnings ...` to show where the warning was created) 👾 Task \u0026#34;eslint\u0026#34; failed when executing \u0026#34;eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern --fix src scripts projenrc .projenrc.ts\u0026#34; (cwd: /home/vscode/workspace/ApexCaptain.IaC) error Command failed with exit code 1. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. ESLint ESLint, 여기서 ES는 Ecma Script의 약자이다. Ecma International이라는 기구에서 만든 Script라는 뜻으로\n통상적으로 표준 Javascript를 의미한다.\nLint 혹은 Linter는 코드베이스를 탐색, 사전에 정의한 규칙에 맞지 않는 코드를 찾아내고 교정하는 도구이다.\n단순하게 우리말로 번역하면 맞춤법 검사기 정도로 보면 된다.\nLint의 의미를 영어사전에서 찾아보면 보풀이라고 나온다.\n세탁기에 있는 보푸라기 필터처럼 문제의 소지가 있는 코드를 걸러내는 것이다.\nESLint는 ES + Lint, 즉 Javascript 맞춤법 검사기이다.\nESLint Migration 당신의 .eslintrc.json, eslint.config.js로 대체되었다.\nESLint를 프로젝트에서 사용하기 위해선 자체적인 설정파일이 필요하다.\n원래 사용하던 설정파일은 .eslintrc.json으로 json 파일이었다.\n이는 Projen이 생성해주는 파일이다.\n에러 내용을 조사해보니 ESLint에서 사용하는 설정파일 방식이 기존 json에서 js로 바뀌었다는 것이다.\n이를 ESLint에서는 flat config라고 한다. ESLint v8.23.0부터 등장이 예고되었던 것으로 바뀌는 점을 간단히 요약하면 다음과 같다.\nParser Option과 ENV 설정은 languageOptions라는 것으로 통합되었다.\n배열방식의 설정으로 변환되었다 (그래서 Flat이라고 하는 모양이다.)\n기존 JSON 방식에서는 오브젝트 루트에 Key를 사용했었는데\n이제는 rule, plugin, files 이런것들 다 하나의 배열에서 처리한다.\n이제 extends 안 쓰고 일반 javascript처럼 모듈을 import(mjs) 혹은 require(cjs) 해서 사용한다.\n솔직한 감상으로는? 좋다. 아주 좋다. ESLint Json 문법이 아주 골치가 아팠는데,\n만일 ESLint에 이제 입문하는 사람이라면 평소 쓰던 스크립트 문법과 비슷해져서 아주 편할 것이다.\nProjen은 여전히 Json으로 만들어준다 하지만 난 Projen 통해서 ESLint 설정을 관리하는데?\n.projenrc.ts를 통해 간편하게 .eslintrc.json 파일이 관리되는 모습이다. Projen이 생성해주는 ESLint 설정 파일은 json이다. Flat Config는 아직 미지원이다.\n사실 이 Flat Config로의 Migration 이슈는 이미 1년 전부터 알고 있었다.\n하지만 Projen으로 관리되는 프로젝트에서는 ESLint의 Wrapper라고 볼 수 있는 Projen에서 자체적으로 지원을 안 해주면 뾰족한 방법이 없어서 방치하고 있었다.\nflat 방식으로 변환 하라는 것도 어디까지나 deprecation warning이라 그렇게 급한 것도 아니고\u0026hellip;\n뭐 나중에 해주지 않을까?\n하는 안일한 생각을 했던 것이다.\nCursor의 진단에 따르면, ESLint 설정 파일에 JSON은 더 이상 쓸 수가 없으니 조치를 취해야 한다고 한다.\n삽질의 시작 방법 1) ESLint의 Major Version을 8로 제한 (다운그레이드) 어찌 보면 젤 심플한 방법이다. 굳이 구태여 최신 버전을 유지 할 필요도 없으니까?\n회사에서 사용하는 프로젝트에도 Projen을 사용하는 경우가 간혹 있는데,\n핵심 로직에 포함되는 컴포넌트도 아니고 기껏해야 맞춤법 검사기이다.\n그냥 구버전으로 제한을 걸어두면 그만이다.\n실제 issue 탭을 조사해보니 그런 답변이 있었다.\n\u0026hellip;\u0026hellip;\n그걸 말이라고 합니까 휴먼? 이건 안된다.\n비즈니스 로직이 걸린 프로젝트면 쿨하게 묻어두고 넘어가겠는데, 개인 플젝에서 이런 흠이 있는 건 찝찝해서 잠에 들 수가 없다.\n방법 2) Projen에서 ESLint 비활성화 하고 ESLint 설정은 수동으로 하기 (Cursor 추천) projenrc.ts에서 ESLint를 비활성화 하고, 아예 자체적으로 eslint.config.js를 만들어서 쓰라는 거다.\nCursor가 추천한 방법인데\u0026hellip; 이건 너무나도 번거로워서 기각했다.\n방법 3) ESLint Migration Config 사용하기 최종적으로 내가 택한 방법이다.\n다행이도 ESLint에서 기존 json을 js로 바꿔주는 스크립트를 NPM에 게시해 뒀던 것이다.\n구체적인 방법을 여기에 적어두진 않겠다. 결국 이 역시 폐기한 방법이니까.\n대신, 시도한 내용을 정리해서 \u0026ldquo;이런식으로 migration 했는데 이게 맞느냐? 하는 내용의 issue\u0026rdquo;를 남겨 두었는데, 그 스크린샷으로 대체하겠다.\n여차저차 이렇게 저렇게 해서 ESLint Flat Config를 사용할 수 있는 상황까지 만들었다.\n근데 너무 설정이 번잡해서 개선을 요청하는 Issue를 남겨두고 답변을 기다렸다.\n반전 Issue 작성까지 끝나고 나서 뒤늦은 샤워를 한다.\n종일 쌓였던 짜증도 따뜻한 물에 녹여 흘려보낸다.\n아직 날이 밝아 잔뜩 화가 난 강아지와 산책을 다녀왔다.\n비가 오지 않아서 다행이다.\n오늘 저녁은 짜장면에 짬뽕, 탕수육도 있다.\n이 호화로운 저녁을 다 먹고 나면 게임을 하든 영화를 보든 해야지.\n돌연듯 핸드폰에서 알림이 온다. 아까 게시한 Issue에 답글이 달렸다.\n이자식들, 뭐라고 하려나?\n되는데요?? 게시한 Issue에 달린 답글은 의외였다.\nESLint v9에서도 기존 방식(Legacy Config)이 아직 사용 가능하다는 내용이다.\n특히나 Mr Grain이 구체적으로 지적한 부분은 .projenrc.ts의 다음 라인이었다.\n1 project.eslint?.eslintTask.env(\u0026#39;ESLINT_USE_FLAT_CONFIG\u0026#39;, \u0026#39;true\u0026#39;); 이 옵션은 ESLint로 하여금 Flat Config를 사용하라라는 의미의 환경변수를 적용하는 코드이다.\nFlat Config에 해당하는 파일이 없는데 ESLint를 실행하려고 하니 최초 발생했던 그 에러가 생길 수 밖에 없다.\n에러 내용을 잘 읽어보면 실제로 그렇게 적혀있다.\n지적한대로 모든 설정을 처음으로 돌려서 다시 실행했더니 정상적으로 ESLint 커맨드가 동작했다.\n1 2 3 4 5 6 $ yarn eslint yarn run v1.22.22 $ npx projen eslint 👾 eslint | eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src scripts projenrc .projenrc.ts (node:7648) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag. (Use `node --trace-warnings ...` to show where the warning was created) Flat Config로 바꾸라는 경고가 뜨긴 하는데, 이건 어디까지 경고일 뿐이다.\njson방식의 설정 파일도 아직 사용하는데 지장이 없다는 거다.\n더욱이 내가 경고문을 제대로 읽어보지 않은 것도 문제였다.\n엄연히 v10부터 지원 끊깁니다라고 적혀 있는데 v9으로 업뎃 하면서 생긴 문제인 줄 알고 호들갑을 떤 것이다.\n얼레? 내가 언제 이런 코드를 작성했지?\n아니\u0026hellip; 이건 내 잘못이 아니지. 문제의 원인은 따로 있다.\n잘 생각해보니, 내가 아직 잠에서 덜 깨서 헤롱헤롱 할 때 멋대로 코드를 이리저리 수정하면서 망가뜨린 주범은 이 녀석이다.\n나 몰래 Flat Config 옵션을 활성화 해놓고 나의 휴일을 망치기 위한 수작을 부린 것이 분명하다!\n정신이 듭니까 휴먼? 사과, 그리고 추후 지원 약속 애꿎은 AI 탓 해서 뭘 하겠는가\u0026hellip; 결국 내가 바보짓을 한 건데.\nMr Grain에게는 미안하다고 코멘트를 달았다.\n그래도 ESLint v10에서는 무조건적으로 Flat Config를 써야한다고 하길래 앞으로 어떻게 할 예정인지도 물어봤다.\n마치며 결국 남은 건 상처뿐인 하루다.\n그나마 ESLint v10에 대한 Projen 개발팀의 지원 약속이라도 받았으니 다행이라면 다행일까.\n아무래도 최근 AI를 지나치게 신뢰하고 또 의존하고 있었던 것은 아닐까 반성하게 되는 하루이다.\n너무 길지 않다면 가급적 경고 / 에러 로그는 직접 읽어보는 습관을 가지도록 하자.\n","date":"2025-10-16T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/fool/projen-eslint-migration-attempt/images/cover_hu_f36d407355b377ee.png","permalink":"https://blog.ayteneve93.com/p/fool/projen-eslint-migration-attempt/","title":"Projen ESLint 9 마이그레이션 시도"},{"content":"\nDOOM 1993년 12월 10일에 발매된 이 게임은 FPS라는 게임 장르의 기본적인 플레이 방법을 문자 그대로 정립했다 라는 평가를 받고 있다.\n개발자들 사이에서 유독 더 인기가 많은데, 이는 오리지널 소스코드를 모두 깃허브에 공개하는 대인배와 같은 행보를 보였던 까닭이다.\n역사의 한 획을 그었던 전설의 게임 둠(DOOM).\n이번 포스트에선 둠의 공개된 소스코드를 통해 id Software 90년대 상남자 개발자들의 코딩 스타일을 살펴 보도록 하겠다.\n하찮은 컴파일러 경고 따위 상남자들의 기개 앞에선 무의미 하다 독특하게 무언가를 지칭하는 대명사 \"it\" 앞에는 항상 \"sh\"를 붙여주는 관습이 있는 모양이다 별도 축약어가 있다면 주석에 반드시 \"무엇의\" 줄임말인지 적어두자 조건문 진입 전에 어떤 상황에 실행되는 건지 간략하게 주석 처리 해주는 좋은 관습이다 조건문의 분기가 많아도 마찬가지다. 반복문에도 적어두자 복잡한 로직 전 코드의 역할이 무엇인지 명시 함수명을 지을 땐 함수의 명확한 역할을 내포하는게 좋다 이건 주석이 아니라 게임 종료시 나오는 메시지이다ㅤ(...번역할 엄두가 안 난다) 마치며 이처럼 DOOM의 소스코드는 90년대 개발자들의 자유분방한 코딩 스타일을 그대로 보여준다.\n요즘은 코드 리뷰와 협업 도구가 발달해서 이런 주석을 보기 힘들지만, 당시에는 이런 것이 개발자들의 일상이었다.\n지금 다니는 회사에서도 이러한 방식을 참고해 코드를 작성해보자.\n시니어 엔지니어의 애정어린 관심을 독차지 할 수 있을 것이다.\n","date":"2025-10-14T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/joke/lets-jump-into-the-doom-source-code/images/cover_hu_5fb9c93af7db2f9a.png","permalink":"https://blog.ayteneve93.com/p/joke/lets-jump-into-the-doom-source-code/","title":"상남자들의 주석 다는 방법"},{"content":"개요 다음은 한국시장에서의 검색엔진 점유율이다.\n구글이 가파르게 성장하고는 있으나 여전히 Naver를 사용하는 사람이 반수 이상임을 알 수 있다.\n하지만 난 Google을 애용하는 한 사람으로써, 그리고 블로그 특성상 Google 이용률이 높은 개발자 분들이 많이\n찾아주실 것 같아 포스트 썸네일은 구글 로고로 해두었다.\n이번 포스트에선 대한민국 1~3등 검색엔진\n네이버\n구글\n다음\n3곳에 본 블로그가 노출될 수 있도록 등록 해보려고 한다.\n본래 지금보다 포스트 수가 더 쌓이면 (뭐 한 50개 정도?) 진행하려고 했는데,\n의외로 글 쓰는게 시간을 엄청나게 잡아먹는다.\n가끔은 소스코드 작성보다도 이게 더 오래 걸린다.\n이러다 올해 다 갈때까지 등록 못 할 것 같아서 여유 있을때 처리 해두려고 한다.\n블로그 설정 파일 수정 사이트를 등록하기 위해선 robots.txt, sitemap.xml 이 2개의 파일이 필요하다.\nconfig.toml파일에 해당 설정을 추가 해줘야 한다.\n1 2 3 4 5 6 7 8 # robots.txt 파일 생성 enableRobotsTXT = true # sitemap.xml 파일 생성 [sitemap] changefreq = \u0026#34;daily\u0026#34; # always, hourly daily, weekly, monthly, yearly, never filename = \u0026#34;sitemap.xml\u0026#34; priority = 0.5 Naver 검색엔진에 노출시키기 네이버 서치어드바이저에 접속한다.\n네이버 계정으로 로그인 한다.\n상단의 웹마스터 도구로 들어간다.\n블로그의 주소를 입력한다. 내 경우 https://blog.ayteneve93.com이었다.\n이는 Cloudflare 도메인을 GitHub Page의 커스텀 도메인으로 추가해서 그런 것이다.\n보통은 https://your-github-username.github.io로 입력하면 된다.\nSite Verification을 위한 html을 등록하라고 한다.\nNaver에서 제공하는 html 파일을 다운로드 받은 후 Hugo 프로젝트의 static 폴더에 넣고 github에 push한다.\n사이트 목록에서 등록한 사이트로 들어가보자.\n여기까지 무리없이 왔다면 사이트 소유권은 확인 된 것이다.\n로봇룰 검증\n검증 -\u0026gt; robots.txt으로 들어가 수집요청을 눌러 로봇룰 검증을 실시한다.\n사이트맵 제출\n요청 -\u0026gt; 사이트맵 제출로 들어가 블로그 주소/sitemap.xml을 입력한다.\nRSS 제출\n요청 -\u0026gt; RSS 제출로 들어가 블로그 주소/index.xml을 입력한다.\nGoogle 검색엔진에 노출시키기 구글 서치 콘솔로 접속 -\u0026gt; 시작하기버튼을 누른다.\n사이트 등록\n새 요소 추가 -\u0026gt; URL 접두어에 블로그 주소를 입력한다.\nSite Verification을 위한 html을 등록하라고 한다.\nNaver와 마찬가지로 Hugo 프로젝트의 static 디렉토리 밑에 다운받은 html 파일을 넣고 github에 push 해주자.\n여담으로 나는 이 부분에서 문제가 발생했었다. 사이트 소유권 검증이 안 되었던 것이다.\n확인 결과 Cloudflare rullset 설정 떄문이란 걸 알았다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 firewallRules = this.provide(Ruleset, \u0026#39;firewallRules\u0026#39;, id =\u0026gt; ({ zoneId: this.cloudflareZoneStack.dataAyteneve93Zone.element.zoneId, name: id, description: dedent` Allow ArgoCD webhooks and all traffic to Blog. Otherwise, block countries except Korea and Japan. `, kind: \u0026#39;zone\u0026#39;, phase: \u0026#39;http_request_firewall_custom\u0026#39;, rules: [ { description: \u0026#39;Allow all traffic to blog\u0026#39;, enabled: true, action: \u0026#39;skip\u0026#39;, expression: `http.host eq \u0026#34;${this.cloudflareRecordStack.blogRecord.element.name}\u0026#34;`, actionParameters: { ruleset: \u0026#39;current\u0026#39;, }, }, { description: \u0026#39;Allow ArgoCD webhooks\u0026#39;, enabled: true, action: \u0026#39;skip\u0026#39;, logging: { enabled: true, }, expression: `http.host eq \u0026#34;${this.cloudflareRecordStack.argoCdRecord.element.name}\u0026#34; and http.request.uri.path contains \u0026#34;/api/webhook\u0026#34;`, actionParameters: { ruleset: \u0026#39;current\u0026#39;, }, }, { description: \u0026#39;Block countries except Korea and Japan\u0026#39;, enabled: true, action: \u0026#39;block\u0026#39;, expression: \u0026#39;(ip.geoip.country ne \u0026#34;KR\u0026#34; and ip.geoip.country ne \u0026#34;JP\u0026#34;)\u0026#39;, }, ], })); 위는 CDK for Terraform으로 작성한 Cloudflare 설정 코드의 일부이다.\n기본적으로 한국과 일본에서만 접속할 수 있도록 해두었는데(규칙 3번),\nGoogle은 국내 기업이 아니어서 생긴 문제였다.\n어떻게 할까 하다가 그냥 blog 레코드는 차단에서 제외했다(규칙 1번).\n비슷한 이슈가 있는 사람이라면 참고하길 바란다.\n사이트맵, RSS 제출\n속성 -\u0026gt; Sitemaps로 들어가 새 사이트맵 추가에 그대로\nsitemap.xml과 index.xml을 입력해 제출한다.\nDaum 검색엔진에 노출시키기 다음 검색등록 페이지로 이동한다.\n블로그 등록을 체크하고 블로그 주소를 입력한다.\nhttp라고 적혀있는 건 신경 쓸 필요 없다.\n개인정보 관련 규정 페이지가 나온다. 동의 후 확인을 눌러주자.\n이메일 주소를 입력하라고 한다. 입력 해주자.\n등록이 완료되면 다음과 같이 나온다.\n","date":"2025-10-12T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/blog/registering-blog-with-search-engines/images/cover_hu_ab46ad43f322e019.png","permalink":"https://blog.ayteneve93.com/p/blog/registering-blog-with-search-engines/","title":"검색 엔진에 Hugo 블로그 등록하기"},{"content":" ⚠ 주의 : 본 포스트에서는 k8s에 Windows를 설치하는 방법에 대한 정보를 담고 있습니다.\nMS 법무팀과 평생 기억에 남을 찐한 티 타임을 가지고 싶은게 아니라면 Windows Container나 Mac OS Container 페이지에도 나와있듯, 별도 라이센스 없이 상업적으로 사용하는 것은 삼가도록 합시다.\n문제의 시작 나는 깔끔한 걸 굉장히 좋아하는 성격이다.\n회사에서나 집에서나 컴퓨터 바탕화면에는 항상 필요한 최소한의 아이콘만 배치하고 있다.\n특히나 Docker를 접하게 된 이후로는 개발자 컴퓨터라면 응당 설치되어 있을법한 python이나 nodejs, java 심지어 Database Client 같은 것들도 모두 컨테이너를 통해 사용하고 있다.\n하지만 반드시 Windows 혹은 Mac에서만 동작하도록 만들어진 프로그램들도 있기 마련이다.\n예컨대 카카오톡의 경우 Docker Container로 사용하기가 대단히 어렵거나 하더라도 매끄럽게 동작하질 않는다.\n이러한 것들 중 내가 특히나 불편하게 여기는 게 있다. 바로 은행 보안 프로그램이다.\n로그인 하려면 이것들부터 설치해야 한다 한 달에 한 번씩 정기적으로 가계부를 정리하는 습관을 가지고 있는데,\n금융 관련 보안에 대해서는 굉장히 민감한 편이라 반드시 집에 있는 메인 PC로만 작업하곤 한다.\n문제는 다음과 같다.\n매번 은행업무 볼 때마다 각종 은행 보안 프로그램이 설치 / 업데이트 / 실행 된다.\n은행들끼리 통일도 안 되어 있는 건지 사용하는 프로그램들이 조금씩 다 다르다.\n개인적으로 사용하는 보안 프로그램과 충돌나는 경우가 잦다.\n은근히 컴퓨터 자원을 많이 잡아 먹는다.\n더 이상 메인 PC에 이런 자질구레한 보안 프로그램들이 설치되는 걸 용납할 수가 없었다.\n그렇다고 은행업무 전용 PC를 산다는 건 지나친 투자이다. 빈대 잡자고 초가삼간 태울 순 없는 노릇 아닌가.\n그렇담 가상화는?\n이 생각도 안 해본 건 아니다. 아니 생각 정도가 아니라 실제로 VMWare로 시도도 해봤다. 동작도 잘 한다.\n그런데, 메인 PC에 은행업무 하나 보자고 가상환경 설치하는 것 자체가 너무나도 번거럽고 부담스러운 일이었다.\n이것도 싫고 저것도 싫고\u0026hellip; 그렇다면 결론은 하나다.\nk8s 클러스터 위에 Windows를 설치하는 수밖에!\n\u0026hellip;\n아니 대체 왜 이렇게까지\u0026hellip;\n사실 진짜 이유 해결방안 - k8s에 Windows를 설치하자! 사용할 컨테이너 이미지는 dockurr/windows이다.\n이게 그 Windows 컨테이너라는 건가?\n싶을 수도 있는데, 아니다.\nWindows Container는 Host OS가 Windows일 때 Windows App을 격리하여 실행하기 위해 있는 것으로, 우리가 흔히 Container라고 부르는 것은 십중팔구 Linux Container를 의미한다. Linux Container와 Windows Container는 근본적으로 다르다.\nLinux Container는 Linux OS 위에서만 동작한다. Unix Container라는 것도 있다. Unix OS에서만 동작한다. 마찬가지로 Windows Container는 Windows 위에서만 동작한다. 같은 개발자분이 작성한 다른 이미지들을 보면 Windows 이외에도 MacOS, Casa등 다양하게 준비되어 있는 걸 볼 수 있는데, 이 이미지들은 모두 Linux 시스템 위에서 동작하는 걸 전제로 만들어져 있다.\n이런게 가능한 이유는 Container 수준이 아니라 아예 커널 레벨에서의 가상화를 쓰기 때문이다.\n핵심 컴포넌트는 KVM(kernel-based Virtual Machine)이다.\nKVM (kernel-based Virtual Machine) KVM은 Linux Kernel을 하이퍼바이저로 변환하는 가상화 기술로, 단일 물리적 컴퓨터에서 여러 개의 격리된 가상 머신(VM), 즉 \u0026ldquo;게스트\u0026rdquo; 운영체제를 실행할 수 있게 해준다.\n각 VM을 메모리, 스토리지, 네트워크 카드와 같은 가상화된 하드웨어 구성 요소를 갖춘 일반 Linux 프로세스로 처리함으로써 효율적이고 고성능의 가상화를 제공한다.\n동작 원리 Linux Kernel 통합: KVM은 Linux Kernel 자체 내의 모듈이기 때문에 하이퍼바이저 역할을 하기 위한 별도의 운영체제가 필요하지 않다.\n하드웨어 가상화: KVM은 Intel VT-x 또는 AMD-V와 같은 하드웨어 가상화 확장 기능을 활용하여 완전한 하드웨어 수준의 가상화를 제공한다.\nQEMU: KVM이 핵심 가상화 기능을 제공하는 반면, QEMU 에뮬레이터와 함께 작동하여 게스트 VM에 하드웨어 에뮬레이션 및 가상 장치를 제공한다.\n가상 머신: 각 게스트 OS는 자체 전용 가상 하드웨어를 갖춘 별도의 Linux 프로세스로 실행되어 다른 VM과의 격리를 보장한다.\n주요 특징 오픈소스 및 무료: KVM은 오픈소스이며 무료로 사용할 수 있다.\n성능: Linux Kernel과의 깊은 통합으로 인해 높은 성능과 효율성을 자랑한다.\n유연성: Linux와 Windows를 포함한 다양한 운영체제를 게스트 VM으로 실행할 수 있다.\n비용 효율성: Linux의 구성 요소로서 값비싼 라이선스 비용이 필요하지 않다.\n일반적인 사용 사례 클라우드 컴퓨팅: KVM은 많은 퍼블릭 및 프라이빗 클라우드 인프라의 핵심 기술로, 확장 가능한 온디맨드 컴퓨팅 리소스를 제공한다.\n소프트웨어 개발 및 테스트: 개발자들은 KVM을 사용하여 다양한 운영체제에서 소프트웨어를 테스트하기 위한 격리된 환경을 만든다.\n서버 통합: 조직에서는 KVM을 사용하여 여러 서버를 더 적은 수의 물리적 머신으로 통합하여 비용과 에너지를 절약한다.\n사전준비 말이 길었는데, 결국 k8s를 구성하는 Node에서 kvm을 지원 해줘야 OS(Linux)의 한계를 뛰어넘는 Container를 사용해 볼 수 있다.\nKVM 지원 확인 KVM 자체는 Linux Kernel에 이미 있기 때문에 하드웨어 가상화만 지원하면 된다. k8s Node에 터미널로 접속해 다음 명령어를 입력해보자.\n1 lsmod | grep kvm 다음과 같이 출력된다면 이미 활성화가 되어 있는 것이다.\n1 2 3 4 kvm_amd 208896 4 kvm 1409024 3 kvm_amd irqbypass 12288 1 kvm ccp 143360 4 kvm_amd Intel의 CPU를 사용한다면 kvm_intel이라고 출력될 것이다.\n출력이 안 되었다면, VMWare나 VirtualBox 설치할 때 처럼 해당 노드의 Bios에서 활성화를 해줘야 한다.\nHost OS가 Debian 계열이라면 다음의 명령어로 더 간편하게 확인 가능하다.\n1 2 sudo apt install cpu-checker sudo kvm-ok Windows 컨테이너 정보 Windows 11을 설치할 것이다. 사양은 Windows 11 시스템 요구사항에 맞춰 구성했다.\n운영체제 : Windows 11 CPU Core : 2 메모리 : 4GB 저장공간 : 64GB kubernetes 구성 실제 구현은 CDK for Terraform Single Stack으로 작업했다.\nNamespace 생성 1 kubectl create namespace windows Windows 사용자 로그인 정보 Secret 생성 1 2 3 4 kubectl create secret generic windows-user-credential-secret \\ -n windows \\ --from-literal=username=\u0026lt;Windows 유저명\u0026gt; \\ --from-literal=password=\u0026lt;Windows 패스워드\u0026gt; 추가 Installation Script Configmap 생성 (선택사항) Container에 Windows가 처음 설치된 이후 1번 실행될 스크립트이다.\nContainer의 /oem/install.bat파일이 실행된다.\n1 2 3 . ├── install.bat └── remove-unnecessary-apps.ps1 이렇게 2개의 파일로 구성되어 있다. 각 파일의 내용은 다음과 같다.\ninstall.bat\n밑의 2번 PowerShell 파일을 실행하는 bat파일이다.\n1 2 3 4 5 @echo off echo Running initial installation scripts... powershell -ExecutionPolicy Bypass -File \u0026#34;%~dp0remove-unnecessary-apps.ps1\u0026#34; remove-unnecessary-apps.ps1\n불필요한 Windows 기본 앱들을 제거해주는 PowerShell 스크립트이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 C:\\WINDOWS\\System32\\OneDriveSetup.exe /uninstall $appNamesToDelete = @( \u0026#34;Clipchamp.Clipchamp\u0026#34;, \u0026#34;Microsoft.BingNews\u0026#34;, \u0026#34;Microsoft.BingSearch\u0026#34;, \u0026#34;Microsoft.BingWeather\u0026#34;, \u0026#34;Microsoft.GamingApp\u0026#34;, \u0026#34;Microsoft.MicrosoftOfficeHub\u0026#34;, \u0026#34;Microsoft.MicrosoftSolitaireCollection\u0026#34;, \u0026#34;Microsoft.MicrosoftStickyNotes\u0026#34;, \u0026#34;Microsoft.OutlookForWindows\u0026#34;, \u0026#34;Microsoft.PowerAutomateDesktop\u0026#34;, \u0026#34;Microsoft.Todos\u0026#34;, \u0026#34;Microsoft.WebMediaExtensions\u0026#34;, \u0026#34;Microsoft.Windows.Photos\u0026#34;, \u0026#34;Microsoft.WindowsAlarms\u0026#34;, \u0026#34;Microsoft.WindowsCalculator\u0026#34;, \u0026#34;Microsoft.WindowsCamera\u0026#34;, \u0026#34;Microsoft.WindowsFeedbackHub\u0026#34;, \u0026#34;Microsoft.WindowsSoundRecorder\u0026#34;, \u0026#34;Microsoft.Xbox.TCUI\u0026#34;, \u0026#34;MicrosoftCorporationII.QuickAssist\u0026#34;, \u0026#34;MSTeams\u0026#34;, \u0026#34;Microsoft.Copilot\u0026#34;, \u0026#34;Microsoft.ZuneMusic\u0026#34;, \u0026#34;Microsoft.ScreenSketch\u0026#34;, \u0026#34;Microsoft.WindowsAppRuntime.1.3\u0026#34;, \u0026#34;Microsoft.Paint\u0026#34;, \u0026#34;Microsoft.YourPhone\u0026#34;, \u0026#34;Microsoft.Windows.DevHome\u0026#34;, \u0026#34;Microsoft.XboxGamingOverlay\u0026#34;, \u0026#34;Microsoft.XboxSpeechToTextOverlay\u0026#34;, \u0026#34;Microsoft.WindowsStore\u0026#34;, \u0026#34;Microsoft.XboxIdentityProvider\u0026#34; ) Write-Host \u0026#39;Removing unnecessary apps...\u0026#39; foreach ($eachAppNameToDelete in $appNamesToDelete) { Get-AppxPackage | Where-Object { $_.Name -eq $eachAppNameToDelete } | Remove-AppxPackage } Write-Host \u0026#39;Unnecessary apps removal completed.\u0026#39; 이는 예시로, 원하는 대로 설치 스크립트를 구성할 수 있다.\n이 두 파일을 담는 ConfigMap을 생성한다.\n1 2 3 4 kubectl create configmap windows-oem-assets-configmap \\ -n windows \\ --from-file=install.bat \\ --from-file=remove-unnecessary-apps.ps1 PersistentVolumeClaim 메니페스트 파일 1 2 3 4 5 6 7 8 9 10 11 12 # pvc.yml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: windows-persistent-volume-claim namespace: windows spec: accessModes: - ReadWriteOnce resources: requests: storage: 64Gi Deployment 메니페스트 파일 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 # deployment.yml apiVersion: apps/v1 kind: Deployment metadata: name: windows-deployment namespace: windows spec: replicas: 1 selector: matchLabels: app: windows template: metadata: labels: app: windows spec: terminationGracePeriodSeconds: 120 containers: - name: windows image: dockurr/windows env: - name: VERSION value: \u0026#39;11\u0026#39; - name: CPU_CORES value: \u0026#39;2\u0026#39; - name: RAM_SIZE value: \u0026#39;4G\u0026#39; - name: DISK_SIZE value: \u0026#39;64G\u0026#39; - name: LANGUAGE value: \u0026#39;Korean\u0026#39; - name: REGION value: \u0026#39;ko-KR\u0026#39; - name: KEYBOARD value: \u0026#39;ko-KR\u0026#39; - name: USERNAME valueFrom: secretKeyRef: name: windows-user-credential-secret key: username - name: PASSWORD valueFrom: secretKeyRef: name: windows-user-credential-secret key: password ports: - name: http containerPort: 8006 protocol: TCP - name: rdp-tcp containerPort: 3389 protocol: TCP - name: rdp-udp containerPort: 3389 protocol: UDP - name: vnc containerPort: 5900 protocol: TCP securityContext: privileged: true capabilities: add: - NET_ADMIN volumeMounts: - name: windows-persistent-volume-claim mountPath: /storage - name: windows-oem-assets-configmap mountPath: /oem - name: dev-kvm mountPath: /dev/kvm - name: dev-tun mountPath: /dev/net/tun volumes: - name: windows-persistent-volume-claim persistentVolumeClaim: claimName: windows-persistent-volume-claim - name: windows-oem-assets-configmap configMap: name: windows-oem-assets-configmap - name: dev-kvm hostPath: path: /dev/kvm - name: dev-tun hostPath: path: /dev/net/tun type: CharDevice ⚠️ 여기서 잠깐!\nKVM이 활성화 된 Node만 필터링 해서 스케쥴링을 해야 할 수도 있다.\n이 경우엔 가상화가 가능한 Node들에 다음의 예시처럼 Labeling을 해주자.\n1 kubectl label node \u0026lt;KVM 사용 가능한 노드의 이름\u0026gt; kvm.enabled=true 이후 deployment.yml의 spec에 nodeAffinity를 다음과 같이 추가해줘야 한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # deployment.yml # --- 기존 코드 --- * spec: template: spec: containers: # --- 기존 코드 --- * volumes: # --- 기존 코드 --- * affinity: nodeAffinity: # 스케쥴링 시엔 \u0026#39;필수\u0026#39; 만족, 실행 중엔 값이 변경되어도 무시 requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: kvm.enabled # 설정한 레이블 키 operator: In values: - \u0026#34;true\u0026#34; # 레이블 값 이렇게 함으로써 KVM 사용이 가능한 Node만 선택적으로 골라내 windows를 배포 할 수 있다.\nService 메니페스트 파일 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # service.yml apiVersion: v1 kind: Service metadata: name: windows-service namespace: windows spec: selector: app: windows ports: - name: http port: 8006 targetPort: 8006 protocol: TCP - name: rdp-tcp port: 3389 targetPort: 3389 protocol: TCP - name: rdp-udp port: 3389 targetPort: 3389 protocol: UDP - name: vnc port: 5900 targetPort: 5900 protocol: TCP Ingress 메니페스트 파일 (선택) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # ingress.yml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: windows-ingress namespace: windows annotations: nginx.ingress.kubernetes.io/backend-protocol: \u0026#39;HTTP\u0026#39; nginx.ingress.kubernetes.io/rewrite-target: \u0026#39;/\u0026#39; # OAuth2 Proxy 사용 시 아래 예시처럼 사용 # nginx.ingress.kubernetes.io/auth-url: \u0026#34;https://oauth2-proxy.example.com/oauth2/auth\u0026#34; # nginx.ingress.kubernetes.io/auth-signin: \u0026#34;https://oauth2-proxy.example.com/oauth2/start?rd=$scheme://$host$request_uri\u0026#34; spec: ingressClassName: nginx rules: - host: windows.yourdomain.com # 도메인으로 변경 http: paths: - path: / pathType: Prefix backend: service: name: windows-service port: number: 8006 매니페스트 배포 1 2 3 4 kubectl apply -f pvc.yml kubectl apply -f deployment.yml kubectl apply -f service.yml kubectl apply -f ingress.yml 배포 상태 확인 1 2 kubectl get pods -n windows kubectl logs -n windows -l app=windows -f 동작 테스트 웹 접속 ingress 설정까지 마쳤다면 연결한 도메인으로 접속해보자.\n별도 도메인이 없다면, 다음의 커맨드로 포트포워딩 후 localhost:8006으로 접속하자.\n1 2 3 4 kubectl port-forward \\ -n windows \\ --address localhost \\ svc/windows-service 8006:8006 처음에 Windows 설치파일을 다운로드 받는다 다운로드가 완료되면 설치가 시작된다 여기서 제법 시간이 오래 소요된다. 커피라도 한 잔 하고 오자.\n설치가 끝나면 Windows 바탕화면이 반겨준다 OEM 스크립트가 정상 동작했다면, 대부분의 불필요한 앱들이 삭제되었을 것이다. 원격 데스크탑(RDP) 접속 웹으로 보는 건 속도랑 반응성이 처참하므로 실 사용에서는 원격 데스크탑을 사용하는 것을 추천한다.\nService 구성에서 아예 NodePort로 3389 포트를 빼주거나 포트포워딩 해서 접속하는 것도 가능하다.\n포스트에 굳이 명시하진 않았는데, 내 경우 Nginx Ingress Controller가 LoadBalancer의 특정 포트를 할당하는 형태로 마무리 했다.\n원격 데스크톱 연결 한계점 클라우드에서 사용하는 경우 앞서 클러스터를 구성하는 Node에서 KVM이 허용 되어야지만 windows pod 생성이 가능하다고 했다.\n본 포스트에서 사용한 Node는 On-Premise 환경에서 동작하는 Desktop 사양의 컴퓨터이다.\n고로 비슷한 환경이라면 십중팔구 문제 없이 적용이 될 것이다.\n하지만 만일 클라우드에서 KVM을 동작하길 원한다면 프로바이더 별로 몇 가지 제약사항이 있다.\nAmazon Web Service (AWS)\nEC2 : 사용 불가능\nEC2는 보안/성능상의 이유로 중첩 가상화(Nested Virtualizaion)를 허용하지 않는다.\nBMI : 사용 가능\nBMI(Bare Metal Instance)는 물리적인 서버 전체를 임대해주는 서비스다.\n이 경우엔 사용 가능하다. (무지하게 비싸서 문제지)\nGoogle Cloud Platform (GCP)\n사용 가능하다.\n다만, 다음의 제약사항을 준수해야 한다.\n인스턴스 생성시 --enable-nested-virtualization 플래그를 넣어줘야 한다. AMD / Arm 프로세서은 지원되지 않는다. Intel CPU만 가능하다. 일부 VM 유형(E2 VM등) 지원되지 않는다. 커맨드 예시:\n1 2 3 4 gcloud compute instances create \u0026lt;사용할 인스턴스 명\u0026gt; \\ --enable-nested-virtualization \\ --zone=\u0026lt;배치할 Zone\u0026gt; \\ --min-cpu-platform=\u0026#34;Intel Haswell\u0026#34; Microsoft Azure\nAzure도 가능은 한데 GCP보다도 더 까다롭다.\nDv3, Ev3등 충분히 큰 크기의 VM에서만 사용 가능하다. Linux VM에 직접 KVM 관련 패키지를 설치해야한다. 1 2 3 4 5 apt-get update apt-get install kvm qemu-kvm libvirt-bin virtinst apt install virt-manager adduser `id -un` libvirt adduser `id -un` kvm 마찬가지로 Linux VM에 KVM 게스트 VM의 인터넷 연결\n및 통신을 위한 가상 브리지, NAT 설정을 직접 구성해야한다. 1 2 3 4 5 6 7 8 9 10 11 iface br0 inet static address 192.168.0.100 network 192.168.0.0 netmask 255.255.255.0 broadcast 192.168.0.255 gateway 192.168.0.1 bridge_ports eth0 bridge_fd 9 bridge_hello 2 bridge_maxage 12 bridge_stp off Oracle Cloud Infrastructure (OCI)\nOracle은 여러가지 옵션을 제공한다.\nBMI: 사용 가능\nOracle Linux KVM Image: 사용 가능\n얘네는 특이하게도 KVM을 사용할 수 있는 별도의 전용 OS를 아예 제공한다.\n하지만 AMD/Arm에선 사용 할 수 없고 Intel CPU에서만 가능하다.\n일반 Oracle Instance: 사용 가능\n가능은 한데, Azure의 경우와 마찬가지로 Linux 안에 libvirt와 같은 패키지를\n직접 설치해야 하고 Shape도 AMD E-Series (예: VM.Standard.E5.Flex) 또는 Intel X-Series (VM.Standard3.Flex) 등을 사용해야 한다.\n리소스 소모량 Linux와는 근본적으로 상이한 OS가 컨테이너로 올라가다 보니\n구체적으로 클러스터에 얼마나 부담이 되는지가 걱정이 되었다.\n스토리지 LongHorn Volume 탭을 확인해보니, 할당한 크기 64GB중 34GB 사용중으로 나온다.\n이 정도면 아주 큰 문제는 없어 보인다 CPU 및 메모리 Prometheus 기록상으론 의외로 CPU는 처음 설치 시점에만 높고 이후에는 잠잠해진다.\n그런데 메모리는 할당한 4GB가 전부 로드되어 있다.\nWindows Container 내부 작업 관리자에서 봐도 제법 많은 메모리가 소모되고 있다.\nWindows 11 자체가 idle 상태에서도 생각보다 많은 메모리가 필요한 모양이다.\n아니면 KVM이 메모리를 Reserved하게 할당하는 걸 수도 있다.\n좀 더 지켜봐야겠지만, 대처방안은 고려 해야겠다.\n필요할 때만 잠깐 배포하거나\n메모리 할당량을 늘리거나\n메모리를 잡아먹는 프로세스를 비활성화 하거나\n이런식으로 접근해야 할 듯 하다.\n마치며 보안에 대해 Windows라는 OS는 기본적으로 사용자명과 비밀번호 이 2가지로 인증 처리를 한다.\n따라서 어떤 형태든 추가적인 보안 레이어를 마련해 둘 필요가 있다.\n가장 좋은 방법은 애초에 외부 접근을 아예 못 하도록 설정하는 것이다.\nService를 보면 총 3개의 포트를 쓰는 것을 볼 수 있다.\nhttp rdp vnc http의 경우 내부적으로 noVNC로 서비스 되는데, 내 경우 ingress annotation에 oauth2 proxy를 설정해서 외부에서 아무나 들어올 수 없도록 추가적인 보안 레이어를 마련해두었다.\n문제는 RDP와 VNC이다. Public으로 이 2가지 프로토콜을 열어두는 건 아무리 생각해도 너무 꺼림칙해 아예 VPN을 통해서만 들어올 수 있도록 설정 해두었다.\n요컨대 Windows에 원격으로 접속/인증하는 방식이 근원적으로 안전하지 않기 때문에 반드시 이 점을 충분히 고려해서 서비스 네트워크를 구성하길 바란다.\n개인적인 의견 idle 상태에서도 windows가 필요로 하는 메모리가 제법 많다는 걸 고려하면 비용 최적화 측면에서 그다지 현명한 접근 방법은 아니라고 본다.\nWindows 혼자 타노스급으로 메모리를 폭식중인 모습이다 심지어 색도 보라색이다 그렇다고 마냥 삐딱하게 볼 건 아니다.\nWindows나 MacOS에서만 동작하는 어떤 프로그램이 있다고 생각해보자.\n당신의 상사가 이를 k8s에 올려서 서비스 하라고 지시했다.\n만일 무슨 짓을 써도 매끄럽게 컨테이너화가 불가능한 상황이 온다면-\nKVM 기반의 컨테이너가 구원의 손길이 될지도 모른다. (물론 정식 라이센스 쓰고)\n","date":"2025-10-11T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/install-windows-on-k8s/images/cover_hu_a50f77294d85ccfd.png","permalink":"https://blog.ayteneve93.com/p/dev/install-windows-on-k8s/","title":"k8s에 Windows 설치하기"},{"content":"연관 포스트 Docling이란? 문제의 시작 회사에서 간단한 RAG 애플리케이션 을 하나 만들라는 지시를 받았다.\n여기엔 몇 가지 단서조항이 포함되어 있었는데, 문제가 되는 부분은 다음과 같다.\n운영 환경은 Windows 11 노트북\n네트워크에 연결되지 않은 상태 에서 동작해야 함\nDocker Desktop 설치하면 안 됨\n중국 기업에서 나온 모델은 사용하면 안 됨\n예를들어 DeepSeek QWEN 추석 연휴동안 작업 하려고 허락 받고 아예 집에 가져왔다. 노트북 사양 CPU/메모리는 괜찮다. 특히 메모리는 무려 64GB나 된다! 문제는 그래픽 카드인데, VRAM이 8GB 밖에 되지 않는다. 폐쇄망 이번 프로젝트에서 가장 큰 걸림돌이 바로 이것이다.\n네트워크가 안 되는 환경에서 구동 되어야 할 것\nOllama처럼 단순히 LLM을 설치하고 명령 받아서 처리만 해주는 컨테이너의 경우, 외부에서 제어만 잘 해주면 별다른 문제가 없겠으나 Docling처럼 AI가 애플리케이션 내부로 들어가서 겉에서 한 번 Wrapping된 형태라면 Offline 기능을 제공해주지 않는 이상 구현이 요원해진다.\n해결방안 컨테이너 구성 우선 전체적으로 컨테이너가 어떻게 구성되어 있는지 정리해두었다.\nHost OS가 Windows인 관계로, WSL 및 Docker와 Docker-Compose를 사용해서 컨테이너 환경을 마련했다.\n단서조항에 Docker Desktop은 설치하면 안 됨이 있어서 Ubuntu 위에 직접 설치했다.\nApplication Web: React 기반의 웹 애플리케이션 Backend: NestJs 기반의 API 서버 Infrastructure Ollama Image: ollama/ollama 용도: 텍스트 생성 / Embedding 사용된 모델 텍스트 생성: joonoh/HyperCLOVAX-SEED-Text-Instruct-1.5B:latest 임베딩: bona/bge-m3-korean:latest GPU 가속 : O Docling Image: quay.io/docling-project/docling-serve\n이 이미지는 CPU Only 모드로만 동작하는 Docling 컨테이너 이미지이다.\nGPU 가속이 가능한 이미지로도 써봤는데, VRAM 제한 때문에 CUDA Out of Memory 이슈와 함께 먹통이 되어버렸다.\n결국 이 프로젝트에서 GPU는 Ollama 컨테이너만 쓰는 것으로 타협을 봤다.\nVRAM에 여유가 있다면 다음의 Docker Image 중 하나를 골라 쓰면 된다.\nquay.io/docling-project/docling-serve-cu126: CUDA 12.6 quay.io/docling-project/docling-serve-cu128: CUDA 12.8 용도: Embedding 전처리\n사용된 모델\nds4sd/CodeFormulaV2: 수학 공식 분석 HuggingFaceTB/SmolVLM-256M-Instruct: 이미지 분석 sentence-transformers/all-MiniLM-L6-v2: 문서 Chunking GPU 가속 : X\nChroma Image: chromadb/chroma 용도: VectorStore Management Watchtower: 컨테이너 자동 업데이트 AutoHeal: HealthCheck Fail시 컨테이너 자동 리스타트 이미지에 AI Model을 내장하기 이번 문제의 핵심을 다시 한 번 요약하면 다음과 같다.\nRAG 애플리케이션을 Offline 상태의 노트북 1개에서 동작시켜야 한다.\n여기서 핵심이 되는 컨테이너는 Ollama와 Docling이다.\n두 컨테이너는 모두 AI 모델을 동적으로 다운받아 동작하는 것을 기본으로 한다.\n그럼, Ollama와 Docling에서 사용할 모델을 Docker Image에 내장하면 그만인 것 아닐까?\n방법 1) Docker Image에 모델 내장해서 올리기 우선 아예 모델 다운로드가 포함된 Image를 만들어서 Registry에 올려보았다.\n10GB가 넘는다. 심지어 필요한 모든 모델을 다 담은 것도 아니다. 당연한 얘기지만, 모델이 포함된 만큼 정직하게 크기가 늘어나버렸다.\n이게 비단 Docker 이미지가 좀 무거워졌다 수준의 문제가 아니다.\nCI/CD 파이프라인이 전반적으로 다 느려진다.\n이렇게 생성된 Image는 클라우드에서 제공하는 Container Registry에 올라가는데, 용량 때문에 비용 걱정도 해야 한다.\n방법 2) 빌드 타임에 AI 모델을 다운받도록 변경 생각해보니, 굳이 Image Registry에 올릴 필요는 없었다.\nOffline 환경에서 동작해야 한다이지,\nOffline 환경에서 설치해야 한다의 개념은 아니지 않은가.\nDocker Compose에 image 대신 build를 넣고 아예 Dockerfile 자체를 정의해주면 그만이다.\n그렇게 해서 나온 결과는 다음과 같다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 # docker-compose.yml services: # ...... # # Infrastructure Services ollama: build: context: ./build/ollama dockerfile: Dockerfile container_name: ollama restart: unless-stopped # Docker Compose에서 GPU를 할당할 땐 이런식으로 한다. deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] volumes: - Ollama.Data:/root/.ollama logging: options: max-size: 50m docling: container_name: docling build: context: ./build/docling dockerfile: Dockerfile restart: unless-stopped environment: DOCLING_SERVE_ENABLE_UI: \u0026#39;false\u0026#39; # (매우 중요!) 이 항목이 없으면 모델을 내장시켜도 Offline에서 자꾸 에러가 난다. HF_HUB_OFFLINE: 1 logging: options: max-size: 50m # ...... # volumes: Ollama.Data: build 디렉토리는 docker-compose.yml과 같은 경로에 배치 해두었다.\nbuild 디렉토리 내부 구조는 다음과 같다.\n1 2 3 4 5 6 build ├── docling │ └── Dockerfile └── ollama ├── Dockerfile └── entrypoint.sh 각 파일들은 다음과 같이 작성했다.\nbuild/docling/Dockerfile\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 FROM quay.io/docling-project/docling-serve-cpu # Docling의 기본 모델 다운로드 RUN docling-tools models download # Hybrid Chunker용 추가 모델 다운로드 RUN python3 -c \u0026#34;from transformers import AutoTokenizer, AutoModel; \\ AutoTokenizer.from_pretrained(\u0026#39;sentence-transformers/all-MiniLM-L6-v2\u0026#39;); \\ AutoModel.from_pretrained(\u0026#39;sentence-transformers/all-MiniLM-L6-v2\u0026#39;);\u0026#34; # 공식/이미지 분석용 모델 다운로드 RUN docling-tools models \\ download-hf-repo \\ ds4sd/CodeFormulaV2 \\ HuggingFaceTB/SmolVLM-256M-Instruct EXPOSE 5001 build/ollama/Dockerfile\n1 2 3 4 5 6 7 8 9 FROM ollama/ollama COPY ./entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT [\u0026#34;/entrypoint.sh\u0026#34;] EXPOSE 11434 Ollama는 Ollama 서버가 실행되고 나서야 모델을 받을 수 있으므로, 별도의 Entrypoint를 추가해줬다.\nbuild/ollama/entrypoint.sh\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/bin/bash set -e echo \u0026#34;Starting Ollama server...\u0026#34; ollama serve \u0026amp; # 백그라운드에서 Ollama 실행 SERVER_PID=$! echo \u0026#34;Waiting for Ollama server to be active...\u0026#34; until ollama list \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; do # Ollama process가 정상적으로 동작 될 때까지 대기 sleep 1 done echo \u0026#34;Pulling models...\u0026#34; # For embedding ollama pull bona/bge-m3-korean:latest || true # For text generation ollama pull joonoh/HyperCLOVAX-SEED-Text-Instruct-1.5B:latest || true trap \u0026#34;kill -TERM $SERVER_PID\u0026#34; SIGTERM SIGINT wait $SERVER_PID 마치며 위 방법 2를 사용해서 Offline에서도 임베딩부터 텍스트 생성까지 정상 동작하는게 확인되었다.\n\u0026ldquo;Docling이란?\u0026rdquo; 포스트의 동작 테스트 문단에 실제로 테스트 한 영상을 올려두었다.\n","date":"2025-10-09T12:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/rag-app-in-an-offline-env/images/cover_hu_9fbe90cd52897bfa.png","permalink":"https://blog.ayteneve93.com/p/dev/rag-app-in-an-offline-env/","title":"오프라인 환경에서 RAG 앱 동작시키기"},{"content":"연관 포스트 오프라인 환경에서 RAG 앱 동작시키기 RAG(Retrieval-Augmented Generation, 검색 증강 생성) 인공지능 시스템을 구축할 때, AI 모델(이하 LLM)에게 회사 내부 문서와 같이 사전에 학습되지 않은 정보를 활용한 답변을 기대한다고 생각해보자.\n1~2개 정도의 PDF 파일이라면 통째로 첨부해서 사용해도 상관 없겠지만,\n수십, 수백 개의 문서들을 모조리 LLM에게 입력할 수는 없는 노릇이다.\n이 문제를 해결하고자 나온 방법론 중 하나가 바로 RAG이다.\nRAG의 정의는 다음과 같다.\nLLM의 출력을 최적화하여 응답을 생성하기 전에 훈련 데이터 소스 외부의 신뢰할 수 있는 기술 자료를 참조하도록 하는 프로세스 소스가 되는 문서(가령 PDF 파일)를 AI가 쉽고 빠르게 관련성을 유추할 수 있는 형태의 데이터(vector)로 변환하고, 사용자의 질문(query)에 맞춰 검색된 데이터를 가져와(retrieving) prompt의 context로 넣어서 동작한다.\n이렇게 문자나 이미지같은 복잡한 데이터를 LLM이 이해하고 처리하기 쉬운 숫자 형태의 Vector로 변환하는 과정 혹은 변환된 결과물 그 자체를 Embedding이라고 하며, 그러한 작업을 수행하는 AI Model을 Embedding Model이라고 한다.\nEmbedding Model이 하는 일은 다음과 같다.\n텍스트/이미지 등을 Vector로 변환, VectorStore(혹은 Vector DB라고도 한다) 에 저장 사용자의 Query를 Vector로 변환, VectorStore에서 유사한 값들을 검색(Retrieval) Embedding 전처리 Chunking Embedding Model이 VectorStore에서 문서 데이터를 가져올 때, 가져온 결과 하나하나는 특별한 사유가 없는 한 있는 그대로 LLM에 전달된다.\n만일 텍스트로만 이루어진 어떤 문서의 글자 수가 5,000개이고, 이 5,000개가 한 덩어리로 VectorStore에 저장되어 있다면 LLM은 엄청난 크기의 Context에 적지 않은 부담을 지게 될 것이다. (혹은 당신의 지갑이\u0026hellip;)\n이에 따라 VectorStore에 문서를 저장할 땐 어떠한 방식으로든 원문을 잘게 잘라(chunking),\n사용자 Query와의 관련성은 유지하면서 불필요하게 많은 Context가 사용되는 일은 피하게 할 필요가 있다.\n1 2 3 4 5 6 7 8 9 10 11 [ { \u0026#34;pageContent\u0026#34;: \u0026#34;하지만 무작정 글자 수나 Token 수에 맞춰\u0026#34; }, { \u0026#34;pageContent\u0026#34;: \u0026#34;잘랐다가는, 이렇게 하나의 문장이 다 끝나\u0026#34; }, { \u0026#34;pageContent\u0026#34;: \u0026#34;기도 전에 잘려진 조각이 만들어질 것이다.\u0026#34; } ] 이와 같은 문제를 막겠다고 일부러 각 조각들 간 겹치는 부분을 만드는 게 일반적이나, 근본적인 해결책은 아니다.\n구조 분석 더욱이 원문이 PDF와 같이 구조화된 형태일 경우(이미지, 그래프, 테이블, 수식 등), 단순하게 텍스트만 추출하고\n구조는 무시해버린다면 최종적으로 LLM이 답변을 낼 때 전혀 엉뚱한 소리를 하는 경우가 생긴다.\n이런 식으로 Text로만 이루어진 경우는 오히려 드물다 Embedding 과정은 전통적으로\n원본 파일에서 Text만 추출 Token 수에 맞춰 쪼개기 Text를 Vectorizing VectorStore에 저장 이렇게 단순하게 이루어져 왔었다. 3/4번은 Embedding 모델이 하는 것이므로 여기선 논하지 않겠다.\n문제는 1/2번인데, 앞서 언급했듯 이렇게 텍스트만 추출하게 되면 원본 문서가 가지고 있던 구조적 특징이 유실되는 문제가 있다.\nPDF Parser나 OCR 같은 도구들을 활용해서 보완할 수는 있겠으나, PDF는 일반적으로 생각하는 것보다\n훨씬 복잡한 형태가 많고, 이를 완벽하게 추출해내는 것은 아직도 매우 어렵다.\nDocling(도클링)이란? Docling은 IBM Research에서 개발한 생성형 AI 애플리케이션을 위한 문서 처리 및 변환을 위한 오픈소스 툴킷이다.\nMIT 라이선스로 공개되어 있어 상업적으로도 자유롭게 활용할 수 있다.\n앞서 얘기한 Embedding 전처리 과정에서 생기는 문제점을 해결하고자 나온 오픈소스로,\n자체적인 인공지능 모델을 활용해 원본 문서를 분석/변환/Chunking 해준다.\n제공해주는 기능은 GitHub에 보다 잘 정리되어 있다.\n기본적으로 다양한 문서 포맷을 지원하고, Page Layout, Order, Table 등을 해석할 수 있으며, LangChain과 쉽게 통합 가능한 것이 특징이다.\n현재 이 글을 쓰고 있는 시점을 기준으로 언급된 기능들은 다음과 같다.\n🗂️ Parsing of multiple document formats incl. PDF, DOCX, PPTX, XLSX, HTML, WAV, MP3, VTT, images (PNG, TIFF, JPEG, \u0026hellip;), and more 📑 Advanced PDF understanding incl. page layout, reading order, table structure, code, formulas, image classification, and more 🧬 Unified, expressive DoclingDocument representation format ↪️ Various export formats and options, including Markdown, HTML, DocTags and lossless JSON 🔒 Local execution capabilities for sensitive data and air-gapped environments 🤖 Plug-and-play integrations incl. LangChain, LlamaIndex, Crew AI \u0026amp; Haystack for agentic AI 🔍 Extensive OCR support for scanned PDFs and images 👓 Support of several Visual Language Models (GraniteDocling) 🎙️ Audio support with Automatic Speech Recognition (ASR) models 🔌 Connect to any agent using the MCP server 💻 Simple and convenient CLI 다른 솔루션들과의 비교 문서 변환은 오랫동안 논의된 주제로, 이미 많은 솔루션이 존재한다. 최근 널리 사용되는 방식은 크게 두 가지로 나뉜다.\n1. VLM(Visual Language Model) 기반 솔루션 Closed-source: GPT-4, Claude, Gemini Open-source: LLaVA 기반 모델들 이러한 생성 모델 기반 솔루션은 강력하지만 다음과 같은 문제점이 있다:\n할루시네이션(Hallucination): 문서 변환 시 정확성이 중요한데, 모델이 존재하지 않는 내용을 생성할 수 있다 높은 계산 비용: 대규모 모델을 사용하기 때문에 비용이 매우 비싸고 비효율적이다 2. Task-specific 모델 기반 솔루션 대표 사례: Adobe Acrobat, Grobid, Marker, MinerU, Unstructured Docling의 접근 방식도 여기에 해당 이 방식은 OCR, 레이아웃 분석, 테이블 인식 등 특화된 모델들을 조합하여 사용한다.\n장점: 할루시네이션 문제가 적고, 정확하고 예측 가능한 변환 결과를 보장 단점: 상대적으로 커버리지가 작고, 다양한 특화 모델을 유지해야 하는 복잡성 Docling의 아키텍처 Docling은 크게 3가지 주요 컴포넌트로 구성되어 있다:\nPipelines: 문서 처리 파이프라인 Parser Backends: 다양한 문서 형식 처리기 DoclingDocument: Pydantic 기반의 통합 문서 표현 모델 Pipeline의 종류 1. StandardPdfPipeline\nPDF 및 이미지 입력을 DoclingDocument 형태로 변환하는 파이프라인 여러 AI 모델들을 단계적으로 사용하여 정보를 구조화 다음과 같은 특화 모델들을 활용: Layout Analysis Model: 페이지 내 각 요소들의 위치와 레이아웃 분석 TableFormer: 테이블 구조를 인식하고 복원 (행/열 정보 보존) OCR Engine: 스캔된 문서나 이미지 내 텍스트 추출 2. SimplePipeline\nPDF를 제외한 다른 문서 형식(DOCX, PPTX, HTML 등)을 처리 상대적으로 단순한 구조로, 빠른 처리가 가능 이러한 모듈 형태의 설계 덕분에 필요에 따라 각 단계를 교체하거나 확장할 수 있는 유연성을 제공한다.\n동작 테스트 오프라인 환경에서 RAG 앱 동작시키기 포스트에서 작업한 내용을 이쪽으로 가져왔다.\n테스트에 사용된 파일은 영화진흥위원회에서 공개한 25년 8월 영화산업 결산 보고서 PDF의 일부이다.\n텍스트 생성에 쓰인 LLM은 joonoh/HyperCLOVAX-SEED-Text-Instruct-1.5B:latest로\n예시로 쓰인 2025년 8월 대한민국 외국영화 흥행작 상위 10위에 대한 정보는 모델에 사전 학습되어 있는 것이 아니다.\n임베딩\n텍스트 생성 테스트\n마치며 프로젝트 제한사항으로 인해 Docling에서 GPU 가속을 못 쓰다 보니, 전반적으로 만족스러운 속도는 아니었다.\n하지만, 표 등이 포함된 소스 파일에서 단순한 텍스트 추출만 해서는 LLM이 이해할 수 있는 형태로\n전달되지 않았던 문제를 해결할 수 있는 좋은 방법이라고 생각한다.\n장단점 정리 장점:\nVLM 기반 솔루션 대비 훨씬 저렴한 비용 MIT 라이선스로 상업적 활용 자유 LangChain, LlamaIndex 등 주요 프레임워크와의 쉬운 통합 오프라인 환경(Air-gapped)에서도 사용 가능 Mac에서 MPS device를 활용한 빠른 처리 지원 단점:\nRuntime에 실시간으로 사용하기보단 RAG 인덱싱용으로 적합 VLM 대비 상대적으로 제한적인 커버리지 권장 사용 사례 Docling은 다음과 같은 경우에 특히 유용하다:\nRAG 시스템을 위한 문서 인덱싱 작업 테이블이나 복잡한 레이아웃이 포함된 PDF 처리 정확성이 중요한 문서 변환 작업 비용 효율적인 문서 처리 파이프라인 구축 이제까지 PDF Parser 같은 기본적인 라이브러리만 사용해봤다면, 한 번 결과를 보고 도입을 고려해봐도 괜찮을 것 같다.\n취향에 따라 Docker Container로 혹은 Python script에 모듈을 설치해 Import할 수도 있고, 아예 CLI로 동작시킬 수도 있다.\n","date":"2025-10-09T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/docling/images/cover_hu_dd431ca817a31074.png","permalink":"https://blog.ayteneve93.com/p/dev/docling/","title":"Docling이란?"},{"content":"개요 현재 이 블로그는 Hugo 프레임워크의 Stack Theme을 적용해서 빌드 되었다.\n이 글을 쓰고 있는 시간 기준으로 아직까지 포스트 된 글의 수가 많지는 않지만 추후 방문자 분들과 소통 해야 할 필요성이 있어 댓글 기능을 추가하고자 한다.\n댓글 시스템의 종류 Hugo Stack Theme의 가이드라인에 따르면 지원되는 댓글 시스템은 다음과 같다.\n시스템 데이터 저장소 주요 장점 주요 단점 Disqus Disqus 서버 기능 풍부, 사용자 익숙도 높음, 스팸 방지. 광고 (무료), 로딩 지연 가능성, 데이터 종속. DisqusJS Disqus 서버 Disqus 기능 + 성능 개선 (클릭 시 로드). 설정 복잡, 결국 Disqus에 의존. Cusdis Cusdis 서버/자체 호스팅 매우 경량, 프라이버시 우선 (광고/추적 없음). 기능 단순, UI 커스터마이징 제한. Twikoo 서버리스 DB 다양한 기능(이미지/Katex), 쉬운 서버리스 배포. 별도 서버리스 환경 구성 필요. Remark42 내 서버 (Boltdb) K8s/Docker에 최적화, 데이터 완전 소유, 프라이버시 보호. 초기 서버 설치 및 관리 필요. Cactus Matrix 네트워크 탈중앙화, 데이터 호스팅 주체 선택 가능. Matrix 계정 필요, 낮은 인지도. Giscus GitHub Discussions 서버 불필요, GitHub 리액션 활용, 경량. GitHub 계정 필수, 비기술 사용자 접근 어려움. Gitalk GitHub Issues 서버 불필요, 경량, 데이터 소유권. GitHub 계정 필수, 기능 단순. utterances GitHub Issues 가장 경량화, 깔끔한 GitHub 스타일 UI. GitHub 계정 필수, 기능 단순. Vssue 다중 Git 플랫폼 GitHub 외 GitLab/Bitbucket 지원, 서버 불필요. Git 계정 필수. Waline 서버리스 DB 라이트웨이트, 쉬운 배포, 다양한 형식 지원. 별도의 서버리스 백엔드 필요. 이 중 가장 경량화 된 방식인 utterances를 사용해서 댓글 시스템을 추가하고자 한다.\n추후 여유가 되면 Remark42 방식으로 k8s에 자체 호스팅을 할 계획이다.\n적용 Utterances 앱 설치 GitHub Marketplace 링크로 들어가 애플리케이션을 설치한다.\nOnly select repositories 선택 → 댓글을 저장할 Public Repository (보통 블로그 repo와 동일한 것으로 한다.)\n블로그 설정 파일 수정 Stack 테마의 경우 config/_default/params.toml 파일을 다음과 같이 수정해주면 된다.\n1 2 3 4 5 6 7 8 9 # config/_default/params.toml [comments] enabled = true provider = \u0026#34;utterances\u0026#34; [comments.utterances] repo = \u0026#34;ApexCaptain/ApexCaptain.github.io\u0026#34; # utterances 댓글이 저장될 저장소 issueTerm = \u0026#34;pathname\u0026#34; label = \u0026#34;comments\u0026#34; 로컬에서 블로그 가동 및 테스트 hugo 커맨드로 로컬 서버를 구동한다\n--cleanDestinationDir 플래그를 함께 넣어줘서 기존에 생성된 public, resources 폴더를 지우고 재생성 해주자.\n1 hugo server --cleanDestinationDir localhost:1313으로 접속해 아무 포스트나 들어가서 하단을 보면 다음과 같이 나온다.\n테스트로 댓글 하나를 달아보자.\n그러면 다음과 같이 markdown이 적용되어 h1 크기의 큼지막한 댓글이 달린 걸 확인 할 수 있다.\n위에서 지정한 대상 Repository의 Issue 탭을 확인 해보면 다음과 같이 Issue에 댓글이 추가되어 있다.\n","date":"2025-10-08T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/blog/setup-utterances-comment-to-hugo/images/cover_hu_6c8d9336b7d788ec.png","permalink":"https://blog.ayteneve93.com/p/blog/setup-utterances-comment-to-hugo/","title":"Utterances로 블로그에 댓글 기능 추가하기"},{"content":"연관 포스트 Oracle Cloud Infrastructure 문제의 시작 OCI Instance(Node)의 Boot Volume 최소값 제한 OCI Free Tier에 따르면 OKE에서 가용할 수 있는 자원은 다음과 같다.\nCPU : 4개 메모리 : 24GB 블록 스토리지 : 200GB 이에 맞춰 Terraform으로 OKE 클러스터와 4개의 인스턴스를 가지는 ARM Node Pool을 생성했다.\n각 Node는 1개의 CPU와 6GB의 메모리를 가진다.\n스토리지의 경우 추후 PVC 생성을 위해서도 쓰이므로 최대한 작게 잡아주려고 했다.\nNode에는 최소한의 용량만, 나머지 대부분은 PVC로 할당 그런데 실제로 생성된 Instance의 정보를 보니 Boot Volume이 비정상적으로 크게 만들어져 있다.\nk8s Node의 역할을 하는 Oracle Instance 중 하나의 Boot Volume 정보 Boot Volume은 문자 그대로 Linux 시스템이 부팅하는데 필요한 기본 디스크로, 윈도우로 치면 C드라이브 같은 존재이다.\n확인 결과, 다음과 같은 이슈가 발생했다.\nInstance의 Boot Volume의 크기는 최소 47Gi (50GB) 이상 할당해줘야 한다.\n당연히 이 용량은 Free Tier에서 제공되는 200GB에서 차감된다.\n이러면 단순히 Node 4개를 만드는 것만으로도 Free Tier의 200GB를 다 써버리게 된다.\n단 1개의 PVC도 생성할 수 없다. Persistent Volume도 마찬가지 1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: PersistentVolumeClaim metadata: name: my-pvc spec: storageClassName: oci-bv #OCI에서 기본적으로 제공하는 Storage Class accessModes: - ReadWriteOnce resources: requests: storage: 100Mi 이처럼 OKE k8s 클러스터에 pvc 하나를 만든다고 가정해보자.\noci-bv는 Oracle에서 기본적으로 제공되는 Storage class로 OCI Block Volume을 저장소로 쓴다.\n약 100MB 정도의 크기를 가지는 작은 PVC이다. 그럼 실제 만들어지는 OCI Block Volume의 크기도 100MB 정도여야 하지 않을까?\n어림도 없다. 여기도 크기 제약이 걸려있어서 최소 47Gi (50GB) 이상을 할당해줘야 한다.\n극단적으로 Node를 1개만 쓴다고 가정해도, PVC는 3개가 한계이다. 각각 50GB씩\u0026hellip; 오라클, 보고 있나?\nPVC 자체도 50GB가 최소라 노드 1개 PVC 3개가 한계이다. 해결방안 NodePool의 스펙 및 수 조정 우선 Node에 50GB씩 기본 할당 되는 건 어쩔 도리가 없기 때문에 Node 수를 타협해야 한다.\n클러스터로써 구색은 맞춰야 하므로 기존 4개에서 2개로 줄였다.\n각각 CPU는 2개, 메모리는 12GB씩 할당해줬다.\n이걸로 벌써 100GB가 날아갔다 시스템 구성 요소 NFS Subdir External Provisioner를 사용해서 별도의 StorageClass를 만들어줘야 한다.\nNFS Storage, 즉 NFS 서버를 Source로 해서 StorageClass를 만들고, 해당 StorageClass를 통해 PVC를 생성하면 NFS 서버에 볼륨이 생성되고 데이터가 저장되는 구조이다.\n주요 구성 요소를 배포 순서에 따라 나열하면 다음과 같다.\nOCI Block Volume\n앞서 Node 2개를 배치했으므로, 사용 가능한 용량 제한은 100GB이다.\n남은 100GB를 모두 사용하는 OCI Block Volume 1개가 필요하다.\nNFS Server\n1에서 생성한 OCI Block Volume을 PVC로 매핑 받아 NFS Storage를 제공하는 서비스이다.\nNFS Subdir External Provisioner\n이번 포스트의 핵심으로, 2에서 생성한 NFS 서버를 Source로 StorageClass를 제공한다.\n(선택) FileBrowser, SFTP Container\n보다 편한 관리를 위한 것으로, 선택사항이다.\noci-bv가 제공하는 PVC는 ReadWriteOnce 모드만 제공되므로 반드시 하나의 Pod에 NFS Container와 함께 정의해줘야 한다.\n사용하는 도구 Terraform: 인프라 자원 관리 (OCI 볼륨, 백업 정책) Kubernetes Manifests: NFS 서버, File Browser, SFTP 서비스 Helm: NFS Subdir External Provisioner 설치 실제 구현은 CDKTF를 써서 하나의 Stack 파일로 구성했다.\nGitHub 링크에서 확인 가능하다.\nTerraform 인프라 구성 OCI 인프라 구성은 Terraform OCI Provider를 사용했다.\nHCL 코드에 프로바이더를 연결하는 과정은 여기선 생략한다. (추후 별도 포스팅 예정)\nOCI 블록 볼륨 1 2 3 4 5 6 7 8 9 10 11 # main.tf resource \u0026#34;oci_core_volume\u0026#34; \u0026#34;nfs_core_volume\u0026#34; { compartment_id = var.compartment_id availability_domain = var.availability_domain size_in_gbs = 100 display_name = \u0026#34;nfs-core-volume\u0026#34; lifecycle { prevent_destroy = true } } 볼륨 백업 정책 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # backup_policy.tf resource \u0026#34;oci_core_volume_backup_policy\u0026#34; \u0026#34;nfs_core_volume_backup_policy\u0026#34; { compartment_id = var.compartment_id display_name = \u0026#34;nfs-volume-backup-policy\u0026#34; schedules { backup_type = \u0026#34;INCREMENTAL\u0026#34; period = \u0026#34;ONE_WEEK\u0026#34; retention_seconds = 60 * 60 * 24 * 7 * 3 # 3주 보관 day_of_week = \u0026#34;SUNDAY\u0026#34; hour_of_day = 2 offset_seconds = 0 offset_type = \u0026#34;STRUCTURED\u0026#34; time_zone = \u0026#34;REGIONAL_DATA_CENTER_TIME\u0026#34; } schedules { backup_type = \u0026#34;FULL\u0026#34; period = \u0026#34;ONE_MONTH\u0026#34; retention_seconds = 60 * 60 * 24 * 30 * 2 # 2개월 보관 day_of_month = 1 hour_of_day = 3 offset_seconds = 0 offset_type = \u0026#34;STRUCTURED\u0026#34; time_zone = \u0026#34;REGIONAL_DATA_CENTER_TIME\u0026#34; } } 백업 스케줄:\n증분(INCREMENTAL) 백업: 매주 일요일 2시, 3주 보관 전체(FULL) 백업: 매월 1일 3시, 2개월 보관 백업 정책 할당 1 2 3 4 5 # backup_policy_assignment.tf resource \u0026#34;oci_core_volume_backup_policy_assignment\u0026#34; \u0026#34;nfs_core_volume_backup_policy_assignment\u0026#34; { asset_id = oci_core_volume.nfs_core_volume.id policy_id = oci_core_volume_backup_policy.nfs_core_volume_backup_policy.id } 변수 정의 1 2 3 4 5 6 7 8 9 10 # variables.tf variable \u0026#34;compartment_id\u0026#34; { description = \u0026#34;OCI Compartment ID\u0026#34; type = string } variable \u0026#34;availability_domain\u0026#34; { description = \u0026#34;OCI Availability Domain\u0026#34; type = string } 출력값 정의 1 2 3 4 5 # outputs.tf output \u0026#34;nfs_volume_id\u0026#34; { description = \u0026#34;NFS Core Volume OCID\u0026#34; value = oci_core_volume.nfs_core_volume.id } nfs_volume_id 값은 PV를 만들 때 필요하다.\n변수 파일 생성 1 2 3 4 cat \u0026gt; terraform.tfvars \u0026lt;\u0026lt; EOF compartment_id = \u0026#34;\u0026lt; 배포할 OCI Compartment의 ID \u0026gt;\u0026#34; availability_domain = \u0026#34;\u0026lt; AD 이름, 예시: ibHX:AP-CHUNCHEON-1-AD-1 \u0026gt;\u0026#34; EOF Terraform 배포 1 2 3 4 5 6 7 8 9 10 11 # Terraform 초기화 terraform init # 인프라 계획 확인 terraform plan -var-file=\u0026#34;terraform.tfvars\u0026#34; # 인프라 배포 terraform apply -var-file=\u0026#34;terraform.tfvars\u0026#34; # 출력값 확인 (볼륨 ID 등) terraform output k8s Manifest 네임스페이스 생성 1 2 3 4 5 # namespace.yaml apiVersion: v1 kind: Namespace metadata: name: nfs-system PersistentVolume 생성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # persistentvolume.yaml apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv annotations: pv.kubernetes.io/provisioned-by: blockvolume.csi.oraclecloud.com spec: storageClassName: oci-bv persistentVolumeReclaimPolicy: Retain capacity: storage: 100Gi accessModes: - ReadWriteOnce persistentVolumeSource: csi: driver: blockvolume.csi.oraclecloud.com volumeHandle: \u0026lt;OCI_VOLUME_OCID\u0026gt; # Terraform에서 Output 된 Volume ID값을 넣어준다. # ocid1.volume.oc1 어쩌구 하는 값이다. Web Console에서 복사해서 가져와도 된다. fsType: ext4 nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: failure-domain.beta.kubernetes.io/zone operator: In values: - \u0026lt;AVAILABILITY_DOMAIN\u0026gt; PersistentVolumeClaim 생성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # persistentvolumeclaim.yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc namespace: nfs-system spec: volumeName: nfs-pv # 위에서 생성한 PV의 이름이다 accessModes: - ReadWriteOnce resources: requests: storage: 100Gi storageClassName: oci-bv ConfigMap (Pod의 SFTP 컨테이너용 SSH 키 관리, 선택사항) 1 2 3 4 5 6 7 8 9 # configmap.yaml apiVersion: v1 kind: ConfigMap metadata: name: nfs-system-sftp-config namespace: nfs-system data: ssh-public-key: | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQ... # SSH 공개키 Service 생성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # service.yaml apiVersion: v1 kind: Service metadata: name: nfs-service namespace: nfs-system spec: selector: app: nfs ports: - name: nfs port: 2049 targetPort: 2049 protocol: TCP # -- 선택 사항 -- # - name: file-browser port: 8080 targetPort: 8080 protocol: TCP - name: sftp port: 22 targetPort: 22 protocol: TCP Deployment 생성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 # deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: nfs-deployment namespace: nfs-system spec: replicas: 1 selector: matchLabels: app: nfs template: metadata: labels: app: nfs spec: # FileBrowser를 안 쓸 거라면 initContainers는 필요 없음 initContainers: - name: init-filebrowser-db image: busybox:1.35 command: - /bin/sh - -c - | mkdir -p /exports/fb-database chown -R 1000:1000 /exports/fb-database chmod -R 755 /exports/fb-database volumeMounts: - name: nfs-storage mountPath: /exports containers: # NFS 서버 컨테이너 (핵심!!) - name: nfs-server image: itsthenetwork/nfs-server-alpine:latest-arm imagePullPolicy: Always ports: - containerPort: 2049 protocol: TCP securityContext: capabilities: add: - SYS_ADMIN - SETPCAP command: - /bin/sh - -c - | mkdir -p /exports/services /usr/bin/nfsd.sh volumeMounts: - name: nfs-storage mountPath: /exports env: - name: SHARED_DIRECTORY value: /exports - name: SHARED_DIRECTORY_2 value: /exports/services # File Browser 컨테이너(선택) - name: file-browser image: filebrowser/filebrowser imagePullPolicy: Always ports: - containerPort: 8080 protocol: TCP securityContext: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 volumeMounts: - name: nfs-storage mountPath: /database subPath: fb-database - name: nfs-storage mountPath: /srv subPath: services env: - name: FB_NOAUTH value: \u0026#39;true\u0026#39; - name: FB_DATABASE value: /database/database.db - name: FB_PORT value: \u0026#39;8080\u0026#39; # SFTP 컨테이너(선택) - name: sftp-server image: jmcombs/sftp imagePullPolicy: Always command: - sh - -c - | chmod o+w /home/sftpuser/data /entrypoint sftpuser::::data ports: - containerPort: 22 protocol: TCP volumeMounts: - name: ssh-keys mountPath: /home/sftpuser/.ssh/keys readOnly: true - name: nfs-storage mountPath: /home/sftpuser/data subPath: services volumes: - name: nfs-storage persistentVolumeClaim: claimName: nfs-pvc - name: ssh-keys configMap: name: nfs-system-sftp-config 컨테이너별 역할\nNFS 서버: Alpine 기반 NFS 서버로 /exports 디렉토리 공유 File Browser: 웹 기반 파일 관리 인터페이스 제공 (선택) SFTP 서버: SSH 키 기반 파일 전송 서비스 (선택) FileBrowser Ingress 구성 (선택) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nfs-ingress namespace: nfs-system annotations: nginx.ingress.kubernetes.io/backend-protocol: HTTP nginx.ingress.kubernetes.io/rewrite-target: / spec: ingressClassName: nginx rules: - host: files.example.com # 소유한 도메인으로 변경 http: paths: - path: / pathType: Prefix backend: service: name: nfs-service port: number: 8080 k8s 리소스 배포 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 네임스페이스 생성 kubectl apply -f namespace.yaml # ConfigMap 생성 kubectl apply -f configmap.yaml # PersistentVolume 생성 kubectl apply -f persistentvolume.yaml # PersistentVolumeClaim 생성 kubectl apply -f persistentvolumeclaim.yaml # Service 생성 kubectl apply -f service.yaml # Deployment 생성 kubectl apply -f deployment.yaml # Ingress 생성 (선택) kubectl apply -f ingress.yaml Helm Helm 저장소 추가 1 2 helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/ helm repo update NFS Subdir External Provisioner Values 파일 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 nfs: # NFS 서버 서비스 주소 # 위 k8s manifest에서 생성한 nfs service의 k8s service dns를 사용했다. server: \u0026#39;nfs-service.nfs-system.svc.cluster.local\u0026#39; path: \u0026#39;/services\u0026#39; # 공유 경로 # StorageClass 설정 storageClass: # StorageClass 이름은 본인이 원하는대로 사용하면 된다 storageClassName: \u0026#39;nfs-client\u0026#39; accessModes: \u0026#39;ReadWriteMany\u0026#39; # PVC 할당 시 Storage에 저장 될 경로 패턴을 의미한다. # 예시의 경우 ./pvc/\u0026lt;네임스페이스 명\u0026gt;/\u0026lt;PVC 명\u0026gt;으로 저장된다. pathPattern: \u0026#39;.pvc/${.PVC.namespace}/${.PVC.name}\u0026#39; 보다 구체적인 Values 설정은 다음의 링크를 참조하길 바란다. Helm Chart 배포 1 2 3 4 5 6 7 8 helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \\ --namespace nfs-system \\ --values values.yaml \\ --wait # 설치 상태 확인 helm list -n nfs-system helm status nfs-subdir-external-provisioner -n nfs-system 검증 및 테스트 리소스 상태 확인 1 2 3 4 5 6 7 8 9 10 11 # Pod 상태 확인 kubectl get pods -n nfs-system # Service 확인 kubectl get svc -n nfs-system # PVC 상태 확인 kubectl get pvc -n nfs-system # StorageClass 확인 kubectl get storageclass 마치며 이제 다른 네임스페이스에서 PVC를 생성해보자.\nFileBrowser와 ingress까지 설정했다면 웹상으로 접근해서 확인할 수 있다.\ningress가 별도로 없다면 다음 명령어로 포트포워딩해서 localhost:8080으로 접근해보자.\n1 kubectl port-forward --address localhost -n nfs-system svc/nfs-service 8080:8080 PVC가 지정된 경로 (.pvc/namespace/pvc-name)에 생성됨을 알 수 있다. 추후 계획 현재 k8s 클러스터에 Vault가 배포되어 있는데, Node의 수가 2개뿐이라 HA (High-Availability) 구성이 안 되고 있다. (최소 3개 이상 필요)\nNFS Provisioner는 외부에 존재하는 NFS Storage를 사용할 수도 있으므로, NAS용 컴퓨터 한 대를 구매해서 NFS 서버를 구축한 뒤 NFS Provisioner의 Source로 활용할 예정이다.\n이는 또 다른 클러스터인 On-Premise에도 적용될 예정이다. On-Premise 클러스터는 Longhorn을 설치해서 PVC를 제공하고 있는데, 이래저래 마음에 안 드는 구석이 많아 심플하게 외부 NAS로 통합하려고 한다.\nLonghorn에 대해서는 조만간 포스팅할 예정이다.\n","date":"2025-10-04T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/nfs-subdir-external-provisioner/images/cover_hu_cb13dbcb91d419b8.png","permalink":"https://blog.ayteneve93.com/p/dev/nfs-subdir-external-provisioner/","title":"Nfs subdir external provisioner"},{"content":"연관 포스트 Nfs subdir external provisioner\nOCI Block Volume 최소 크기 제한사항 해결\nKaaS (Kubernetes as a Service) 정의 Kubernetes를 클라우드에서 관리형 서비스로 제공하는 모델이다.\n특징 컨테이너화된 애플리케이션의 배포, 확장, 관리 등의 업무를 간소화 웹 콘솔, Terraform 등을 통해 k8s 클러스터를 자동화된 방식으로 구축 가능 클라우드와 통합된 StorageClass, LoadBalancer 제공 예시 가장 대표적인 Public Cloud Provider인 AWS, Azure, GCP에서는 다음과 같은 KaaS를 제공한다.\n항목 AWS EKS Azure AKS Google GKE 지원 버전 1.16.8 (2020년 5월) 1.18.1, 1.18.2 (2019년 5월) 1.16.8 (2020년 4월) 업데이트 Master 및 Node 자동 업데이트 Master 및 Node 온디맨드 업그레이드 Master CLI 업그레이드, Node 수동 업데이트 CLI 지원 지원 지원 지원 리소스 모니터링 Stackdriver Azure Monitor 타사 도구만 지원 Node 자동 확장 지원 프리뷰 단계 지원 Node 그룹 지원 미지원 지원 고가용성 지원 개발 중 지원 베어메탈 Node 미지원 미지원 AWS 제공 Master 업데이트 자동 수행 수동 수행 수동 수행 Node 업그레이드 자동 수행 수동 수행 관리형/비관리형 그룹 On-Premise AWS Outposts 지원 Anthos GKE Oracle Cloud Infrastructure Oracle Cloud Infrastructure(이하 OCI)는 Oracle에서 운영하는 Cloud Provider이다.\nAWS, Azure 등과 마찬가지로 OCI 역시 Oracle Kubernetes Engine(이하 OKE)라는 KaaS를 제공한다.\nOracle을 선택한 이유 OCI 자체는 다른 Cloud Provider들에 비해 특별한 장점이 있는 것은 아니다.\n하지만 개인적으로 Cloud에 k8s를 구성하길 희망한다면, 그에 부합하는 한 가지 커다란 이점이 있다.\nOracle에는 무려 상시 무료 서비스가 존재한다!\n클러스터 하나를 운영하기에는 부족함이 없다. AWS를 무료로 사용할 수 있는 기간은 1년이 한계이고, 그마저도 EKS는 포함되지도 않는다.\nEKS로는 Node 하나 없이 깡통 클러스터만 만들어 놔도 시간당 $0.1씩 과금된다.\n환율 1,400원 기준으로 계산하면 한 달에 무려 10만원씩 나간다!\n회사 차원에서 도입을 고려하는 경우라면, 이 정도 비용 격차는 그다지 큰 메리트는 아닐 것이다.\n오히려 후술한 단점들을 생각한다면 AWS나 Azure를 선택하는 것이 훨씬 합리적이다.\n하지만 개인 용도 + 학습을 목표로 하는 나와 같은 입장의 방문자가 있다면 썩 괜찮은 선택지이다.\n단점 레퍼런스 부족: 어디서 정보를 찾기가 너무 힘들다. 대부분의 개발자들은 그 존재조차 모르는 것 같다.\n부실한 공식 문서: 그럼 공식 문서라도 깔끔하게 되어있어야 하는데 그것도 아니다.\nAWS에 익숙해져서 그런 것도 있겠지만, OCI의 공식 문서는 객관적으로 봐도 가독성이 매우 떨어진다.\n공식 문서 사이트 링크가 있으니 궁금한 사람은 부디 들어가서 탐험 해보길 바란다.\nOCI Always Free 제한사항 OCI Free Tier 페이지에도 나와있는데, 모든 서비스가 무제한으로 사용 가능한 건 당연히 아니다.\n클러스터 1개를 운영한다고 가정하고, k8s에서 가용 가능한 자원을 간단하게 요약하면 다음과 같다.\n자원 유형 제한사항 Node Arm 기반 Ampere A1 코어 4개, 24GB 메모리 Persistent Volume 200GB Load Balancer Flexible Network Load Balancer 1개 Node의 경우 Node 1개당 최소 1개의 코어는 필요하므로 최대 사용 가능한 Arm Node 수는 4개이다.\nAMD Node도 있긴 한데, 사이즈가 너무 작아서 여기선 무시한다.\nOKE로 만드는 클러스터는 1개까지 무료로 사용 가능하다.\nOKE는 KaaS이기 때문에 별도로 Control-Plane Node가 필요 없다.\n따라서 4개의 Arm Node는 모두 Worker Node로 쓸 수 있다! 그것도 공짜로! 아마존, 보고 있나?\n목표 OCI Free Tier에 제한된 리소스 안에서 k8s 클러스터를 자체적으로 운영 추가 비용 X On-Premise 클러스터와 함께 멀티 클러스터 구성 ","date":"2025-10-03T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/oracle-cloud-infrastructure/images/cover_hu_7dcbe59bfd9518b2.png","permalink":"https://blog.ayteneve93.com/p/dev/oracle-cloud-infrastructure/","title":"Oracle Cloud Infrastructure"},{"content":"문제의 시작 윈도우 11로 업데이트 하면서 몇 가지 불편해진 점이 있다.\n예를들어 :\n여러 압축 파일을 각각의 폴더에 해제 할 수 있던 기능이 없어졌다. 파일이나 폴더를 우클릭 -\u0026gt; 추가 옵션 표시를 눌러줘야 기존에 쓰던 기능을 쓸 수 있다. 그중 하나가 바로가기 파일이 윈도우 작업 표시줄에 드래그 앤 드롭으로 추가가 안 된다는 것이다.\n이번 포스트에선 Cursor나 VsCode 작업 영역(Workspace)의 바로가기를 만들고\n이를 작업 표시줄에 추가하는 방법을 공유하겠다.\n해결방안 Workspace 파일 생성 Cursor 혹은 VsCode로 Workspace파일을 생성한 후 원하는 이름으로 변경한다.\n생성한 Workspace 파일을 적당히 아무 위치로 옮겨준다.\n내 경우 C:\\에 넣어줬다.\n바로가기 생성 Workspace 파일의 바로가기를 만들어서 바탕화면으로 옮겨준다.\n하는 김에 아이콘도 이쁜 걸로 바꿔줬다.\n바로가기 파일 속성 편집 바로가기 파일 우클릭 -\u0026gt; 속성 -\u0026gt; 대상 값 앞에 explorer라고 추가해준다.\n작업 표시줄에 바로가기 등록 편집한 바로가기 파일을 작업 표시줄에 추가한다.\n마치며 집 메인 PC 바탕화면 작업 표시줄 오른쪽의 3개 아이콘은 모두 서로 독립된 개발 서버로 연결되는 Cursor Workspace이다.\n이중 하나를 누르면 SSH연결까지 자연스럽게 Cursor IDE로 접속된다.\n윈도우 검색으로도 들어갈 수 있다.\n추가한 파일 자체가 바로가기이기 때문에 비단 VsCode나 Cursor의 Workspace 뿐 아니라 모든 종류의 바로가기를 다 이런식으로 등록할 수 있다.\n","date":"2025-09-30T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/etc/how-to-add-workspace-to-win11-taskbar/images/cover_hu_4a5fd2cdd6b4c2e8.png","permalink":"https://blog.ayteneve93.com/p/etc/how-to-add-workspace-to-win11-taskbar/","title":"Win11 작업 표시줄에 Cursor Workspace 올리는 법"},{"content":"들어가기 앞서 회사와 무관하게 개인적으로 관리하는 k8s 클러스터는 다음과 같다.\n클라우드: OKE(EKS/GKE와 유사)처럼 기본 L4/L7 Load Balancer 제공 온프레미스: 집에서 운영하는 싱글 노드로 시작한 k8s, 리소스 집약적 워크로드 배치(Jellyfin, 7 Days To Die, Ollama, QBittorrent 등) Jellyfin같은 앱은 클라우드에 설치하기엔 자원 소모량이 너무 크다. 내부망 대역: 93.5.22.0/24 초기 Worker Node: 93.5.22.44 공유기 포트포워딩: 외부 80/443 → 93.5.22.44:80/443, 클러스터 내 Nginx Ingress Controller가 도메인 기반 라우팅 로드밸런서란?\n트래픽을 여러 노드/파드로 분산하고, 가상 IP(VIP)를 통해 단일 진입점을 제공 하드웨어 전용 장비도 있으나, 온프레미스 k8s에서는 소프트웨어 방식이 일반적 Bare Metal 환경에서는 Metallb가 사실상 표준 솔루션 Metallb 핵심 개념\n데몬셋 speaker가 호스트 네트워크로 동작하며 외부 IP를 네트워크에 광고 LoadBalancer 서비스의 External IP 전파에 표준 프로토콜 사용: ARP(IPv4), NDP(IPv6), BGP 두 가지 모드 L2(ARP/NDP): 같은 서브넷에서 VIP를 광고 — 가정/소규모 환경에 적합 BGP: 라우터와 경로를 교환 — 데이터센터/고급 네트워크에 적합 관련 개념 GARP(Gratuitous ARP): 자신의 IP-MAC 정보를 네트워크에 알리는 ARP, VIP 전환 시 필수 Strict ARP: 노드가 소유하지 않은 IP에 응답하지 않도록 제한, L2 모드와 IPVS에서 안전성 향상 문제의 시작 초기에는 단일 노드(93.5.22.44)로 포워딩하면 충분했지만, 노드가 추가되면 단일 대상 포워딩만으로는 고가용성/확장성 확보 불가 이런 상황이라면 공유기는 대체 어느 Node로 포워딩을 해야하는가? Kubernetes의 LoadBalancer 타입이 클라우드 밖에서는 기본 제공되지 않아, 외부에서 접근 가능한 VIP가 부재 결과적으로, 인입 트래픽을 안정적으로 받아 서비스로 라우팅할 수 있는 소프트웨어 로드밸런서가 필요 해결방안 — Metallb(L2 모드) 도입 같은 서브넷(93.5.22.0/24)에서 VIP를 광고하는 L2 모드로 간단하게 시작 예시 VIP: 93.5.22.100 (공유기 포트포워딩은 이 VIP로 설정) 설치(Helm) 1 2 3 4 helm repo add metallb https://metallb.github.io/metallb helm repo update kubectl create namespace metallb-system helm upgrade --install metallb metallb/metallb -n metallb-system --wait 설치 후 CRD 확인:\n1 kubectl get crd | grep metallb.io 다음과 같이 출력되면 정상이다. 1 2 3 4 5 6 7 8 bfdprofiles.metallb.io 2025-08-03T16:07:22Z bgpadvertisements.metallb.io 2025-08-03T16:07:22Z bgppeers.metallb.io 2025-08-03T16:07:22Z communities.metallb.io 2025-08-03T16:07:22Z ipaddresspools.metallb.io 2025-08-03T16:07:22Z l2advertisements.metallb.io 2025-08-03T16:07:22Z servicebgpstatuses.metallb.io 2025-08-03T16:07:22Z servicel2statuses.metallb.io 2025-08-03T16:07:22Z 이중 IPAddressPool과 L2Advertisement CRD를 Manifest로 생성 해줘야 한다. IPAddressPool: Metallb이 할당할 수 있는 IP 주소(VIP) 풀이다. L2Advertisement: Layer2 광고 정의. L2 모드에서 Metallb이 IP 주소를 어떻게 광고하는가를 설정한다. IP 풀/광고 리소스 생성 ipaddresspool.yaml:\n1 2 3 4 5 6 7 8 apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: workstation-ip-pool namespace: metallb-system spec: addresses: - 93.5.22.100 Note: addresses는 범위로 지정할 수도 있다. 예를들면 192.168.0.100-192.168.0.104\n적용:\n1 kubectl apply -f ipaddresspool.yaml l2advertisement.yaml:\n1 2 3 4 5 6 7 8 apiVersion: metallb.io/v1beta1 kind: L2Advertisement metadata: name: workstation-l2adv namespace: metallb-system spec: ipAddressPools: - workstation-ip-pool 적용:\n1 kubectl apply -f l2advertisement.yaml 동작 확인 체크리스트 1 2 3 4 5 6 7 8 # 컨트롤러/스피커 파드 상태 kubectl get pods -n metallb-system -o wide # 풀/광고 리소스 상태 kubectl get ipaddresspools.metallb.io,l2advertisements.metallb.io -n metallb-system # LoadBalancer 서비스의 외부 IP 할당 여부 kubectl get svc -A | grep LoadBalancer || true 공유기 포트포워딩 외부 80/443 → VIP 93.5.22.100:80/443로 설정 참고(안전한 네트워킹을 위해) Strict ARP를 활성화하면, 노드가 소유하지 않은 IP에 응답하지 않아 ARP 오류를 방지 kube-proxy IPVS 모드 사용 시에도 Strict ARP는 중요 BGP 모드는 외부 라우터와의 동적 라우팅이 필요할 때 선택 추가로 Nginx Ingress Controller가 VIP로 들어온 요청을 각 서비스로 라우팅하도록 설정했다 마무리 온프레미스 k8s에서 Metallb는 외부 트래픽을 받는 가장 간단하고 표준적인 방법이다. 같은 서브넷에서는 L2 모드만으로도 VIP를 안전하게 광고하여, 노드 수가 늘어나도 일관된 진입점을 유지할 수 있다.\n","date":"2025-09-29T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/install-metallb-to-on-premise-k8s/images/cover_hu_2787b180de080629.png","permalink":"https://blog.ayteneve93.com/p/dev/install-metallb-to-on-premise-k8s/","title":"On-Premise k8s에 Metallb 설치"},{"content":"\n⚠ 주의 : 본 포스트에서는 k8s에 VPN으로 우회되는 Torrent 서버 구성에 대한 정보를 담고 있습니다.\n부적절한 컨텐츠는 VPN이고 뭐고 절대 받지 맙시다! 👮\n문제의 시작 Jellyfin으로 지인들과 편하게 미디어를 함께 보고 싶어졌다. 귀칼이 그렇게 재밌다길래 미디어 콘텐츠를 구하는 가장 좋은 방법은 Torrent이다. 하지만 이를 위해 메인 PC를 하루종일 켜두고 싶지는 않다. 보안을 위해 VPN은 쓰되, 메인 PC 전체 트래픽을 VPN에 묶고 싶지는 않다. 해결방안 k8s 클러스터에 qBittorrent 배포\nVPN 구성을 위해 NordVPN의 서비스인 NordLynx를 적용\nIngress를 구성해서 보유한 도메인을 통해 접근 가능하도록 설정 (선택)\nqBittorrent의 기본 UI는 너무 못생겼으므로 UI도 VueTorrent로 변경 (선택)\n참고로 기본 UI는 이렇게 생겼다. VueTorrent UI는 이렇게 생겼다. 이쁘다! 구성 개요 시크릿: NordLynx 개인키(nord-lynx-private-key)를 담는 Opaque 타입 시크릿. 서비스(Service): ClusterIP로 qBittorrent 웹 및 토렌트 포트 노출. 디플로이먼트(Deployment): 사이드카 컨테이너 ghcr.io/bubuntux/nordlynx:latest (VPN) 메인 컨테이너 lscr.io/linuxserver/qbittorrent:latest initContainer로 커널 파라미터 설정(sysctl) NET_ADMIN capability 부여(터널 동작용) fsGroup: 1000으로 퍼미션 정리 Ingress(선택): NGINX Ingress 네임스페이스 생성 늘 그렇듯 처음은 Namespace부터 만든다. 이번 포스트에서는 일관성 있게 torrent라는 이름을 사용한다.\n1 kubectl create namespace torrent NordVPN Access Token 발급 NordVPN Dashboard에 접속 후 좌상단 NordVPN을 클릭한다. 2. 하단에 Get Access Token 버튼 클릭 3. 등록된 이메일로 인증코드 전송 4. 새 토큰을 생성한다 Private Key 생성 Docker 명령어를 통해 Private Key를 생성한다.\n\u0026lt;YOUR_ACCESS_TOKEN\u0026gt; 자리에 위에서 생성한 토큰 값을 넣어준다.\n1 docker run --rm --cap-add=NET_ADMIN -e TOKEN=\u0026lt;YOUR_ACCESS_TOKEN\u0026gt; ghcr.io/bubuntux/nordvpn:get_private_key | grep \u0026#34;Private Key:\u0026#34; | cut -d\u0026#39; \u0026#39; -f3 | tr -d \u0026#39;\\n\u0026#39; 시크릿 생성 \u0026lt;YOUR_PRIVATE_KEY\u0026gt; 자리를 위에서 Docker Command로 생성한 Private Key 값으로 교체한다.\n1 2 kubectl -n torrent create secret generic torrent-nord-lynx-private-key \\ --from-literal=nord-lynx-private-key=\u0026#39;\u0026lt;YOUR_PRIVATE_KEY\u0026gt;\u0026#39; Service 생성 service-qbittorrent.yaml:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 apiVersion: v1 kind: Service metadata: name: qbittorrent namespace: torrent spec: type: ClusterIP selector: app: qbittorrent ports: - name: web port: 8080 targetPort: 8080 protocol: TCP - name: torrenting-tcp port: 6881 targetPort: 6881 protocol: TCP - name: torrenting-udp port: 6881 targetPort: 6881 protocol: UDP 적용:\n1 kubectl apply -f service-qbittorrent.yaml PVC 생성 StorageClass가 별도로 없다면 비워도 상관 없다.\n가급적 qbittorrent-complete는 HDD에 qbittorrent-incomplete는 SSD에 저장하는 것이 좋다.\npvc-qbittorrent.yaml:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 # qBittorrent의 설정을 저장하는 PVC apiVersion: v1 kind: PersistentVolumeClaim metadata: name: qbittorrent-config namespace: torrent spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi storageClassName: \u0026lt;your-storage-class\u0026gt; # 클러스터 StorageClass 명에 맞게 수정 --- # 다운로드가 완료 된 파일들이 저장될 PVC apiVersion: v1 kind: PersistentVolumeClaim metadata: name: qbittorrent-complete namespace: torrent spec: accessModes: - ReadWriteOnce resources: requests: storage: 500Gi # 용량은 본인이 원하는 만큼 할당 storageClassName: \u0026lt;your-storage-class\u0026gt; --- # 아직 다운로드 중인 파일들이 저장될 PVC apiVersion: v1 kind: PersistentVolumeClaim metadata: name: qbittorrent-incomplete namespace: torrent spec: accessModes: - ReadWriteOnce resources: requests: storage: 200Gi # 용량은 본인이 원하는 만큼 할당 storageClassName: \u0026lt;your-storage-class\u0026gt; 적용:\n1 kubectl apply -f pvc-qbittorrent.yaml Deployment 생성 deployment-qbittorrent.yaml:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 apiVersion: apps/v1 kind: Deployment metadata: name: qbittorrent namespace: torrent spec: replicas: 1 selector: matchLabels: app: qbittorrent template: metadata: labels: app: qbittorrent sidecar.istio.io/inject: \u0026#39;false\u0026#39; # Istio Sidecar와 NordLynx간의 충돌이 있다. spec: securityContext: fsGroup: 1000 initContainers: - name: init-sysctl image: busybox command: - /bin/sh - -c - | sysctl -w net.ipv6.conf.all.disable_ipv6=1 \u0026amp;\u0026amp; sysctl -w net.ipv4.conf.all.src_valid_mark=1 securityContext: privileged: true containers: - name: nordlynx image: ghcr.io/bubuntux/nordlynx:latest imagePullPolicy: Always env: - name: TZ value: Asia/Seoul - name: NET_LOCAL value: \u0026#39;10.244.0.0/16\u0026#39; # 클러스터 Pod CIDR에 맞게 수정 - name: ALLOW_LIST value: qbittorrent.torrent.svc.cluster.local - name: DNS value: \u0026#39;1.1.1.1,8.8.8.8\u0026#39; - name: PRIVATE_KEY valueFrom: secretKeyRef: name: torrent-nord-lynx-private-key key: nord-lynx-private-key - name: QUERY value: \u0026#39;filters[servers_groups][identifier]=legacy_p2p\u0026#39; - name: COUNTRY_CODE value: JP # 우회를 원하는 국가 코드 기입 securityContext: capabilities: add: - NET_ADMIN - name: web image: lscr.io/linuxserver/qbittorrent:latest imagePullPolicy: Always ports: - containerPort: 8080 protocol: TCP - containerPort: 6881 protocol: TCP - containerPort: 6881 protocol: UDP env: - name: PUID value: \u0026#39;1000\u0026#39; - name: PGID value: \u0026#39;1000\u0026#39; - name: TZ value: Asia/Seoul - name: WEBUI_PORT value: \u0026#39;8080\u0026#39; - name: TORRENTING_PORT value: \u0026#39;6881\u0026#39; # VueTorrent UI 적용 - name: DOCKER_MODS value: ghcr.io/gabe565/linuxserver-mod-vuetorrent volumeMounts: - name: qbittorrent-config mountPath: /config - name: qbittorrent-complete mountPath: /downloads - name: qbittorrent-incomplete mountPath: /downloads/incomplete volumes: # qBittorrent 설정 파일 - name: qbittorrent-config persistentVolumeClaim: claimName: qbittorrent-config # 다운로드가 완료된 파일들 - name: qbittorrent-complete persistentVolumeClaim: claimName: qbittorrent-complete # 다운로드 중인 파일들 - name: qbittorrent-incomplete persistentVolumeClaim: claimName: qbittorrent-incomplete 적용:\n1 kubectl apply -f deployment-qbittorrent.yaml Ingress 생성(선택) Ingress는 옵션이다. 실 사용에서는 Cloudflare 레코드로 도메인을 연결하고\nk8s 내부에는 nginx ingress controller를 설치해서 사용하고 있다.\n또한, 인증된 사용자만 접근할 수 있도록 별도로 OAuth2 Proxy로 보호받는다.\n여기에 대해서는 추후 포스팅 예정.\ningress-qbittorrent.yaml:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: qbittorrent namespace: torrent annotations: nginx.ingress.kubernetes.io/backend-protocol: \u0026#39;HTTP\u0026#39; nginx.ingress.kubernetes.io/rewrite-target: \u0026#39;/\u0026#39; spec: ingressClassName: nginx rules: - host: torrent.your-domain.com # 실제로 연결할 도메인 선택 http: paths: - path: / pathType: Prefix backend: service: name: qbittorrent port: number: 8080 적용:\n1 kubectl apply -f ingress-qbittorrent.yaml 참고 NET_LOCAL은 클러스터 Pod CIDR에 맞춘다.\nPRIVATE_KEY는 위 시크릿 참조와 일치시킨다.\ninitContainer는 커널 파라미터를 조정하므로 privileged가 필요하다.\nNET_ADMIN capability가 없으면 VPN 터널이 정상 동작하지 않는다.\nVPN 네트워크가 끊어지면 모든 토렌트 서비스가 셧다운 된다.\nService Type은 Cluster IP로 설정했는데, 이는 Nginx Ingress Controller와 연결하기 위함이다.\n환경에 따라 NodePort나 LoadBalancer를 사용해서 접근 할 수 있도록 하자.\n마치며 WebUI에서 로그를 검색해보면 정상적으로 VPN IP가 할당되었음을 알 수 있다. 최종적으론 이렇게 나온다 ","date":"2025-09-29T00:00:00+09:00","image":"https://blog.ayteneve93.com/p/dev/torrenting-with-vpn-on-k8s/images/cover_hu_8456111e6777ab29.png","permalink":"https://blog.ayteneve93.com/p/dev/torrenting-with-vpn-on-k8s/","title":"VPN + qBittorrent 설치"},{"content":"GitHub 주요 목표 멀티 클러스터: OCI OKE(클라우드) + Workstation microk8s(온프레미스) GitOps 파이프라인: ArgoCD 중심의 선언적 배포 자동화 보안·신뢰: Bastion, OAuth2 Proxy, Cert-Manager, Vault 중심 시크릿·인증 체계 관찰성: Prometheus + Grafana 모니터링 스택 개인 미디어 인프라: Jellyfin, qBittorrent, 게임 서버 등 아키텍처 개요 네트워크(OCI): VCN + Public/Private/DB Subnet, LB/Bastion, K8s 노드, 데이터 계층 클러스터 계층 OKE(클라우드): System(NS)에 Istio, ArgoCD(개발 중), Vault(개발 중), 모니터링(개발 중), Ingress Workstation(microk8s): System(NS)에 Istio/모니터링/Longhorn, Application(NS)에 Dev/Media/Game/File 서비스 현재 성과(핵심) 인프라 자동화 OKE 및 Workstation 클러스터 구성 정의, 선택적 스택 배포/병렬화, 상태 백업 스크립트 제공 보안/신뢰 Bastion 접근 제어, OAuth2 Proxy, Cert-Manager 자동 인증서 발급 Vault 기반 시크릿 관리 체계 설계계 운영 효율 Prometheus/Grafana 관찰성 스택 구성, 로그 중앙화 계획 개인 미디어/유틸 서비스(Jellyfin, qBittorrent, SFTP, 7 Days to Die) 개발 생산성 src/terraform/stacks 중심 스택화 구조, scripts/ 내 배포 선택/백업/터미널 도구 Projen, ESLint/Prettier, Yarn 워크플로우 정착 진행 현황(요약) OKE 클러스터 자동 프로비저닝 Workstation microk8s 클러스터 Istio 서비스 메시 ArgoCD(GitOps) Vault(시크릿) Prometheus/Grafana(모니터링) Bastion/인증·인증서(Cert-Manager, OAuth2 Proxy) 미디어/게임/SFTP 서비스 ","date":"2025-09-28T18:05:00+09:00","image":"https://blog.ayteneve93.com/p/dev/apexcaptain.iac/images/cover_hu_792953d1e3eb6c33.png","permalink":"https://blog.ayteneve93.com/p/dev/apexcaptain.iac/","title":"ApexCaptain.IaC"}]