<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>youngkyoo_kim.log</title>
        <link>https://velog.io/</link>
        <description>engineer</description>
        <lastBuildDate>Tue, 12 May 2026 19:47:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>youngkyoo_kim.log</title>
            <url>https://velog.velcdn.com/images/youngkyoo_kim/profile/7ee4ca13-1034-49bb-b6ab-736c3065a14a/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. youngkyoo_kim.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/youngkyoo_kim" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[26M13b]]></title>
            <link>https://velog.io/@youngkyoo_kim/26M13b</link>
            <guid>https://velog.io/@youngkyoo_kim/26M13b</guid>
            <pubDate>Tue, 12 May 2026 19:47:17 GMT</pubDate>
            <description><![CDATA[<p>주요 포함 내용 요약:
인프라 운영 및 관리: 멀티 클러스터 운영, 커널 파라미터 튜닝, 일상 운영(K8s, Cilium, AIStor 등).</p>
<p>신규 구축 및 확장: 지역별 클러스터 및 AIStor 구축/업그레이드 계획.</p>
<p>기술 고도화: KubeVirt 도입, AI 기반 오류 분석 자동화, K8sGPT, Agentic AI 도입 검토.</p>
<p>보안 및 표준화: Vault를 통한 키 관리, CI/CD 보안 강화, 운영 SOP(장애/변경/보안/자동화) 템플릿 표준화.</p>
<p>성능 최적화: Core Component(Runtime, etcd, Scheduler, Network, DNS) 튜닝 및 리소스 최적화.</p>
<p>==</p>
<p>작년에 성공적으로 구축하신 클라우드 네이티브 데이터 레이크하우스 환경을 기반으로, 이제 본격적인 <strong>&#39;Day 2 운영(안정화, 최적화, 확장성 확보)&#39; 및 SRE(Site Reliability Engineering) 체계</strong>로 전환하시려는 명확하고 훌륭한 목표입니다.</p>
<p>제시해주신 목표(정/부 페어 운영, GitOps/문서 기반 SSOT, HA 및 템플릿화)를 달성하기 위해, <strong>인력 할당 및 과제 수행 체계를 세우기 전 가장 중점을 두어 준비하고 합의해야 할 5가지 핵심 영역</strong>을 정리해 드립니다.</p>
<hr>
<h3 id="1-정부pair-체계의-실질적-정착-프로세스-강제화">1. 정/부(Pair) 체계의 실질적 정착 (프로세스 강제화)</h3>
<p>단순히 역할만 나누는 것을 넘어, &#39;부담당자&#39;가 &#39;정담당자&#39; 부재 시 완벽히 백업할 수 있는 <strong>제도적 강제성</strong>이 필요합니다.</p>
<ul>
<li><strong>GitOps PR(Pull Request) 리뷰 의무화:</strong> 모든 인프라/설정 변경(Git push) 시, 반드시 부담당자의 Approve를 거쳐야만 Merge 및 Deploy(ArgoCD 등)가 되도록 파이프라인 정책을 설정해야 합니다.</li>
<li><strong>Runbook 공동 작성 및 교차 검증:</strong> 정담당자가 Runbook(장애 대처, 확장 스크립트 등)을 작성하면, <strong>부담당자가 해당 문서만 보고 실제 테스트 환경에서 그대로 실행해보는 검증 단계</strong>를 거쳐야 합니다.</li>
<li><strong>모의 장애 훈련 (Game Day) 주도:</strong> 정기적인 장애 대응 훈련 시, 의도적으로 정담당자를 배제하고 부담당자가 작성된 Runbook과 대시보드만을 활용해 장애를 해결하는 문화를 정착시켜야 합니다.</li>
</ul>
<h3 id="2-ssotsingle-source-of-truth-확립-문서와-코드의-유기적-결합">2. SSOT(Single Source of Truth) 확립: 문서와 코드의 유기적 결합</h3>
<p>&#39;Git 기반 운영&#39;과 &#39;Confluence 문서 기반 운영&#39;이 파편화되지 않도록 연결 고리를 만들어야 합니다.</p>
<ul>
<li><strong>수동 개입(ClickOps/수동 kubectl) 원천 차단:</strong> 긴급 장애(Emergency) 상황을 제외한 모든 클러스터 변경은 Git을 통해서만 이루어지도록 클러스터 권한(RBAC)을 엄격히 통제해야 합니다. 긴급 수동 변경 시, 사후 Git 동기화 프로세스(SOP)를 엄수해야 합니다.</li>
<li><strong>Jira - Confluence - Git Commit 규칙 연동:</strong> 작업 내역의 추적성을 위해 규칙을 정립해야 합니다.</li>
<li>예: Git Commit 메시지에 반드시 Jira 티켓 번호 포함 (<code>feat: [INFRA-123] Update Keycloak HA config</code>)</li>
<li>Confluence 작업 계획서에 해당 Git PR 링크 및 관련 SOP 링크 첨부</li>
</ul>
<h3 id="3-자산-재활용을-위한-철저한-모듈화-및-템플릿화-iac">3. &#39;자산 재활용&#39;을 위한 철저한 모듈화 및 템플릿화 (IaC)</h3>
<p>향후 타 사이트 유사 클러스터 구축 시 공수를 최소화하려면, 현재 환경이 특정 환경에 종속되지 않게 분리되어야 합니다.</p>
<ul>
<li><strong>설정(Config)과 뼈대(Template)의 분리:</strong> Helm Chart, Kustomize, Ansible Playbook 작성 시 환경 변수(dev, stg, prd, 지역 등)를 철저히 Value 파일로 분리하여 관리해야 합니다.</li>
<li><strong>표준 파이프라인 자산화:</strong> Jenkins Shared Library를 활용하여, 개발팀이 가져다 쓸 수 있는 선언적(Declarative) 파이프라인 템플릿을 구축하고 문서화하는 것을 최우선 과제로 두어야 합니다.</li>
</ul>
<h3 id="4-actionable-alert-실행-가능한-알람-및-runbook-맵핑">4. Actionable Alert (실행 가능한 알람) 및 Runbook 맵핑</h3>
<p>1,000여 개의 노드와 거대한 시스템에서 발생하는 알람 피로도(Alert Fatigue)를 줄이고, 즉각적인 대응 체계를 만들어야 합니다.</p>
<ul>
<li><strong>알람과 Runbook의 1:1 매칭:</strong> Alert Manager에서 Slack/Teams 등으로 알람을 쏠 때, 알람 내용 하단에 <strong>반드시 해당 장애를 해결할 수 있는 Confluence Runbook 링크가 포함</strong>되도록 템플릿을 구성해야 합니다.</li>
<li><strong>노이즈 감소 및 알람 등급화:</strong> 치명적 장애(Call-out)와 단순 경고(업무시간 내 확인)를 분리하고, CPU 일시적 스파이크 등 불필요한 로깅과 알람을 제거하는 &#39;Observability 최적화&#39;를 초기에 집중적으로 수행해야 합니다.</li>
</ul>
<h3 id="5-핵심-컴포넌트의-단일-장애점spof-제거-및-한계-테스트">5. 핵심 컴포넌트의 단일 장애점(SPOF) 제거 및 한계 테스트</h3>
<p>운영 안정성의 핵심은 주요 모듈의 완벽한 HA 구성과 성능 한계치를 명확히 아는 것입니다.</p>
<ul>
<li><strong>상태 저장(Stateful) 서비스의 HA 우선 확보:</strong> Keycloak, Vault, Nexus, AIStor 등 데이터와 세션을 관리하는 서비스의 이중화/다중화를 우선 검증해야 합니다. (단순한 Pod 레플리카 증가가 아닌, DB/토큰/세션 클러스터링 동기화 확인)</li>
<li><strong>부하 테스트(Load Test) 기반 튜닝:</strong> eBPF(Cilium), CoreDNS, Ingress 등의 커널/네트워크 튜닝은 단순 값 설정으로 끝나지 않고, 실제 예상 트래픽 이상의 부하를 발생시켜(LoadTest) 병목이 해소되었는지 수치화하여 Confluence에 기록해야 합니다.</li>
</ul>
<hr>
<h3 id="💡-다음-단계-제안-인력-할당-및-운영-체계운영-vs-프로젝트-병행-구축">💡 다음 단계 제안: 인력 할당 및 운영 체계(운영 vs 프로젝트 병행) 구축</h3>
<p>위의 중점 사항들을 바탕으로, 팀원들이 &#39;일상적인 운영/장애 대응(Toil)&#39;과 &#39;고도화 과제(Project)&#39;를 동시에 원활히 수행하기 위한 인력 할당 체계(예: 7:3 업무 분할, 혹은 스프린트 별 운영 담당자 로테이션 제도 등)를 설계하는 것이 좋습니다.</p>
<p>다음 단계로, <strong>올해 진행할 전체 Task들을 정/부 담당자 역할에 맞게 어떻게 배분하고 스프린트(Sprint) 주기로 운영할지 구체적인 워크플로우나 체계 설계</strong>를 도와드릴까요?</p>
<p>==</p>
<p>리드 1명, 시니어(책임/수석) 7명, 주니어 4명으로 구성된 총 12명의 조직은 &#39;기술 고도화 과제(Project)&#39;와 &#39;안정적 운영(Daily Ops)&#39;을 병행하기에 매우 이상적인 구조입니다. 특히 K8s와 Cilium에 대한 운영 가용성을 높이기 위해 &#39;매트릭스형 전담 조직&#39;과 <strong>&#39;운영 로테이션(On-call)&#39;</strong> 제도를 결합한 배분 방안을 제안합니다.</p>
<hr>
<h2 id="1-전담-분야별-기술-필러technical-pillar-구성">1. 전담 분야별 기술 필러(Technical Pillar) 구성</h2>
<p>단순히 일을 나누는 것이 아니라, 11명의 엔지니어를 4개의 핵심 필러로 나누어 <strong>정/부 담당자 체제</strong>를 구축합니다. 모든 필러에는 시니어와 주니어를 혼합 배치하여 기술 전수와 운영 안정성을 동시에 꾀합니다.</p>
<p>| 기술 필러 | 주요 담당 Task</p>
<p> | 인력 구성 (정/부) |
| --- | --- | --- |
| <strong>Pillar 1: Core Infra &amp; K8s/Cilium</strong> | K8s/Cilium/etcd 튜닝 및 업그레이드, BGP/ECMP 구축, 커널 파라미터 튜닝 | <strong>시니어 3명 + 주니어 1명</strong> (Cilium 가용성 집중) |
| <strong>Pillar 2: DevOps &amp; Security</strong> | CI/CD 파이프라인 표준화, GitOps, Keycloak HA, Vault, Harbor 구축 | <strong>시니어 2명 + 주니어 1명</strong> |
| <strong>Pillar 3: Storage &amp; Observability</strong> | AIStor 확장, Local PV/Isilon 연동, 모니터링 체계 고도화, PIR 체계 | <strong>시니어 1명 + 주니어 1명</strong> |
| <strong>Pillar 4: Innovation &amp; MLOps</strong> | KubeVirt, K8sGPT, Agentic AI, GPU 서버 도입, Vibe Coding Zone | <strong>시니어 1명 + 주니어 1명</strong> |</p>
<hr>
<h2 id="2-주간-운영-엔지니어duty-engineer-로테이션-도입">2. &#39;주간 운영 엔지니어(Duty Engineer)&#39; 로테이션 도입</h2>
<p>특정 인원이 운영에만 매몰되지 않도록, 매주 2명(시니어 1, 주니어 1)을 &#39;주간 운영 엔지니어&#39;로 지정합니다.</p>
<ul>
<li><strong>운영 엔지니어의 역할</strong>: 해당 주차의 모든 JIRA 티켓 응대, 모니터링 알람 1차 대응, 단순 변경 작업 수행.</li>
</ul>
<ul>
<li><strong>프로젝트 엔지니어의 역할</strong>: 운영 엔지니어를 제외한 나머지 인원은 본인 필러의 고도화 과제(Task) 수행에 집중.</li>
</ul>
<ul>
<li><strong>효과</strong>: K8s와 Cilium은 모든 시니어가 로테이션에 참여함으로써 자연스럽게 운영 노하우가 상향 평준화됩니다.</li>
</ul>
<hr>
<h2 id="3-직급별-역할-및-과제-배분-가이드라인">3. 직급별 역할 및 과제 배분 가이드라인</h2>
<h3 id="lead-1명"><strong>Lead (1명)</strong></h3>
<ul>
<li>전체 로드맵 관리 및 필러 간 기술적 의사결정 조율.</li>
</ul>
<ul>
<li>Jira 기반 외부 요청 필터링 및 업무 우선순위 확정.</li>
</ul>
<h3 id="시니어-책임수석-7명"><strong>시니어 (책임/수석 7명)</strong></h3>
<ul>
<li><strong>정(Primary)</strong>: 담당 분야 설계(Design Document), 고도화 과제(Task) 리딩, 핵심 Runbook 초안 작성.</li>
</ul>
<ul>
<li><strong>부(Secondary)</strong>: 다른 필러 시니어의 PR(Pull Request) 리뷰, 장애 시 2차 에스컬레이션 대상.</li>
</ul>
<ul>
<li><strong>Cilium 가용성</strong>: 시니어 3명을 핵심 필러에 배치하여, 1명 부재 시에도 나머지 2명이 상호 백업하도록 구성.</li>
</ul>
<h3 id="주니어-4명"><strong>주니어 (4명)</strong></h3>
<ul>
<li><strong>실행 및 운영</strong>: 시니어가 설계한 IaC 코드 구현, 모니터링 대시보드 현행화, Runbook 검증.</li>
</ul>
<ul>
<li><strong>기술 습득</strong>: 정/부 체계에서 &#39;부&#39; 담당자로서 시니어의 설계를 리뷰하고, 운영 로테이션을 통해 인프라 전반의 대응 능력 배양.</li>
</ul>
<hr>
<h2 id="4-운영-체계-가동-프로세스">4. 운영 체계 가동 프로세스</h2>
<h3 id="step-1-gitops--pr-기반의-운영-peer-review"><strong>Step 1: GitOps &amp; PR 기반의 운영 (Peer Review)</strong></h3>
<ul>
<li>모든 설정 변경(K8s Config, Cilium Policy 등)은 Git 저장소에 Commit 후 PR을 생성합니다.</li>
</ul>
<ul>
<li>반드시 담당 필러의 시니어 혹은 운영 파트너의 승인(Approve)이 있어야 반영 가능하도록 설정하여 인적 오류를 방지합니다.</li>
</ul>
<h3 id="step-2-confluence-기반-runbook-중심-운영"><strong>Step 2: Confluence 기반 Runbook 중심 운영</strong></h3>
<ul>
<li>&quot;문서가 없으면 작업도 없다&quot;는 원칙을 적용합니다.</li>
</ul>
<ul>
<li>주니어는 모든 작업 후 결과 보고 대신 <strong>Runbook 업데이트</strong>를 수행하고, 시니어는 이를 검수합니다.</li>
</ul>
<h3 id="step-3-주간-스크럼-및-pir사후-분석-공유"><strong>Step 3: 주간 스크럼 및 PIR(사후 분석) 공유</strong></h3>
<ul>
<li>매주 스크럼 시, 운영 로테이션 엔지니어가 한 주간 발생한 주요 이슈와 해결 과정을 공유합니다.</li>
</ul>
<ul>
<li>Cilium과 같은 고난도 컴포넌트 이슈 발생 시, 담당 필러가 상세 분석(RCA)을 진행하고 전체 인원이 학습하도록 세션을 운영합니다.</li>
</ul>
<hr>
<p>이 체계가 정착되면 <strong>Cilium 이탈과 같은 인력 변동</strong>에도 조직 전체가 유연하게 대응할 수 있는 <strong>지속 가능한 SRE 기반</strong>이 마련될 것입니다. 우선 4개 필러에 인원을 매칭하는 것부터 시작해 보시겠습니까? 구체적인 인원 성함이나 성향을 알려주시면 더 세부적인 매칭안을 제안해 드릴 수 있습니다.</p>
<p>==</p>
<p>제시해주신 인력 구성(L1, S1<del>S7, J1</del>J4)에 맞춰, <strong>기술 필러(Pillar)별 정/부 담당 체계</strong>와 <strong>K8s/Cilium 운영 가용성 극대화</strong>를 위한 할당 안을 작성해 드립니다. 모든 인원은 본인의 중점 과제(Task)를 수행하면서, 로테이션을 통해 운영(Ops) 능력을 상향 평준화합니다.</p>
<hr>
<h2 id="1-기술-필러별-인력-할당-task-중심">1. 기술 필러별 인력 할당 (Task 중심)</h2>
<p>각 시니어(S)는 특정 분야의 <strong>정 담당</strong>으로서 설계를 주도하고, 주니어(J) 및 타 시니어와 페어를 이뤄 기술을 자산화합니다.</p>
<p>| 필러 (Pillar) | 중점 Task</p>
<p> | 인력 할당 (정/부) |
| --- | --- | --- |
| <strong>P1. Core &amp; Network</strong> | k8s/Cilium 업그레이드, 커널 튜닝, BGP/ECMP, 클러스터 헬스체크</p>
<p> | <strong>정: S1, S2, S3</strong><br></p>
<p><br><strong>부: J1, S4</strong> |
| <strong>P2. DevOps &amp; Security</strong> | Keycloak HA, Vault, CI/CD 표준화, GitOps, Harbor, 레지스트리 보안</p>
<p> | <strong>정: S4, S5</strong><br></p>
<p><br><strong>부: J2, S1</strong> |
| <strong>P3. Storage &amp; Obs</strong> | AIStor PB 확장, Isilon 연동, Observability 최적화, PIR 체계</p>
<p> | <strong>정: S6</strong><br></p>
<p><br><strong>부: J3, S2</strong> |
| <strong>P4. Next Tech</strong> | KubeVirt, K8sGPT, Agentic AI, GPU 서버, Vibe Coding Zone</p>
<p> | <strong>정: S7</strong><br></p>
<p><br><strong>부: J4, S5</strong> |</p>
<ul>
<li><strong>L1 (Lead)</strong>: 전체 로드맵 관리, 필러 간 우선순위 조율, JIRA 티켓 최종 검토 및 의사결정.</li>
</ul>
<ul>
<li><strong>S1, S2, S3 (Cilium 집중)</strong>: 기존 인력 이탈에 대응하여 시니어 3명을 전면 배치, 상호 백업 체계를 구축합니다.</li>
</ul>
<hr>
<h2 id="2-k8scilium-운영-체계-ops-중심">2. K8s/Cilium 운영 체계 (Ops 중심)</h2>
<p>모든 담당자가 대응 가능하도록 <strong>&#39;주간 운영 당번(Duty)&#39;</strong> 제도를 도입하여 실무 운영 능력을 배양합니다.</p>
<h3 id="운영-로테이션-weekly-duty"><strong>운영 로테이션 (Weekly Duty)</strong></h3>
<ul>
<li><strong>구성</strong>: 시니어 1명 + 주니어 1명 (매주 교대)</li>
</ul>
<ul>
<li><strong>역할</strong>: 해당 주차의 모든 모니터링 알람 대응, JIRA를 통한 외부 기술지원 요청 처리, 일상적인 K8s/Cilium 운영.</li>
</ul>
<ul>
<li><strong>Cilium 가용성 확보</strong>: 운영 당번이 해결하지 못하는 Cilium 심화 이슈는 P1(S1~S3)이 즉시 투입되는 2차 에스컬레이션 구조를 가집니다.</li>
</ul>
<hr>
<h2 id="3-정부-담당자pair-실무-가이드">3. 정/부 담당자(Pair) 실무 가이드</h2>
<p>인력 변동 및 휴가 시에도 중단 없는 운영을 위해 아래 원칙을 준수합니다.</p>
<ul>
<li><strong>정(Primary) 담당자 (S1~S7)</strong>:</li>
<li>담당 과제의 아키텍처 설계 및 IaC(Terraform, Ansible 등) 코드 작성.</li>
</ul>
<ul>
<li>Confluence에 장애 대응 및 확장 Runbook 초안 작성.</li>
</ul>
<ul>
<li>주니어(J)의 기술 역량 강화를 위한 코드 리뷰 및 가이드.</li>
</ul>
<ul>
<li><strong>부(Secondary) 담당자 (S, J 혼합)</strong>:</li>
<li>정 담당자가 작성한 모든 코드의 <strong>Peer Review(PR 승인)</strong> 수행.</li>
</ul>
<ul>
<li>정 부재 시 Runbook에 기반하여 독자적인 장애 대응 수행.</li>
</ul>
<ul>
<li>주기적으로 정 담당자와 업무 싱크업(Sync-up) 미팅 진행.</li>
</ul>
<hr>
<h2 id="4-gitops-기반의-자동화-및-자산화">4. GitOps 기반의 자동화 및 자산화</h2>
<ul>
<li><strong>Git 기반 SSOT</strong>: 모든 클러스터 설정 및 정책은 Git 저장소에 기록하며, <strong>부 담당자의 승인</strong> 없이는 운영 환경 반영을 금지합니다.</li>
</ul>
<ul>
<li><strong>Runbook 현행화</strong>: 모든 작업 결과는 Confluence에 기록하며, 주니어(J)는 운영 당번 수행 시 발생한 신규 오류 패턴을 문서화하는 역할을 맡습니다.</li>
</ul>
<p>이와 같은 구조로 시작하면, <strong>S1~S3를 통한 Cilium 운영 안정성</strong>을 확보함과 동시에 <strong>주니어 4명의 빠른 기술 습득</strong>과 <strong>전체 인원의 K8s 상향 평준화</strong>를 달성할 수 있습니다.</p>
<p>이 할당 안을 바탕으로 첫 번째 주간 운영 로테이션 순번을 정해볼까요?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26M13a]]></title>
            <link>https://velog.io/@youngkyoo_kim/26M13a-1iq3revx</link>
            <guid>https://velog.io/@youngkyoo_kim/26M13a-1iq3revx</guid>
            <pubDate>Tue, 12 May 2026 19:41:31 GMT</pubDate>
            <description><![CDATA[<p>먼저 <strong>중점 준비 사항</strong>을, 그 다음 <strong>운영 체계 설계</strong> 전체를 시각화해서 보여드릴게요.파일의 65개 태스크를 전부 분석했습니다. 지금 여건에서 <strong>가장 먼저 잡아야 할 것</strong>과 <strong>어떻게 굴릴지</strong>를 순서대로 설명드릴게요.</p>
<hr>
<p><strong>중점 준비 사항부터 말씀드리면</strong>, 크게 세 가지 이유에서 우선순위가 갈립니다.</p>
<p>첫째, <strong>운영 기반(SOP/문서/Git체계)</strong>이 없으면 정/부 페어 자체가 작동하지 않습니다. 부담당자가 정담당자 부재 시 주도적으로 대응하려면 절차가 명문화되어 있어야 하고, 설정과 소스가 Git에 있어야 합니다. 이게 없는 상태에서 과제를 아무리 많이 진행해도 인력 이탈 한 명에 흔들립니다.</p>
<p>둘째, <strong>모니터링/알람 노이즈 정비</strong>를 먼저 해야 합니다. 지금 알람이 너무 많거나 의미 없는 알람이 섞여 있으면 스프린트 중 일상 운영이 계속 방해받고, 정작 중요한 이슈를 놓칩니다.</p>
<p>셋째, <strong>인증(Keycloak) HA와 etcd 백업/컴팩션</strong>은 나머지 모든 서비스의 기반이라, 안정화가 늦어지면 다른 과제 리스크가 덩달아 올라갑니다.</p>
<p>아래는 이를 바탕으로 한 <strong>전체 운영 체계 설계</strong>입니다.---</p>
<p><strong>핵심 요약</strong>을 텍스트로 정리하면 다음과 같습니다.</p>
<p><strong>① 지금 당장 시작해야 할 것 (P0, Q1 내)</strong>은 SOP 4종·PIR 체계·Git 소스 관리 표준·오류 패턴 플레이북·알람 노이즈 정비입니다. 이것이 없으면 부담당자가 정담당자 없이 장애 대응을 할 수가 없고, 인력 변동 때마다 운영이 흔들립니다. 과제 진행보다 이게 먼저입니다.</p>
<p><strong>② 스프린트 capacity</strong>는 BAU 40% / Sprint 과제 40% / 버퍼 20%로 잡는 걸 권장합니다. 처음에 과제 비중을 너무 높게 잡으면 일상 운영에서 불이 나고, 그 불을 끄다가 스프린트가 흐지부지됩니다.</p>
<p><strong>③ 정/부 페어에서 가장 중요한 것</strong>은 부담당자가 단순히 &quot;보조&quot;가 아니라 &quot;언제든 주도할 수 있는 사람&quot;이 되는 것입니다. 이를 위해 문서 peer review와 복합 작업 공동 수행을 Sprint 내에 명시적으로 할당해야 합니다. 자연스럽게 두면 부담당자는 항상 뒷전이 됩니다.</p>
<p>다음 단계로 넘어갈 때 도움이 필요하시면 말씀해 주세요. 예를 들어 <strong>실제 인원 수를 알려주시면</strong> 도메인-인력 매핑을 더 구체적으로 설계해드릴 수 있고, <strong>Q1 Sprint 백로그</strong>를 직접 뽑아드릴 수도 있습니다.</p>
<p>==</p>
<ol>
<li>중점 준비 사항
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/e087018a-ee41-445c-a75f-e68cbc76fd03/image.png" alt=""></li>
</ol>
<p>==</p>
<ol start="2">
<li>스프린트 운영 체계
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/9b31c572-1e47-4fe5-8c44-e0babf756e33/image.png" alt=""></li>
</ol>
<p>==</p>
<ol start="3">
<li>도메인 과제 배분
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/9294e65e-92c9-45b2-91ce-758fede18190/image.png" alt=""></li>
</ol>
<p>==</p>
<ol start="4">
<li>변경 워크플로우
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/273d7ba7-c840-4c5a-a6ee-f338d857e4c7/image.png" alt=""></li>
</ol>
<p>======</p>
<ol start="5">
<li>팀 구성 배분
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/e63daaeb-25de-439b-88eb-628947964b0f/image.png" alt=""></li>
</ol>
<p>==</p>
<ol start="6">
<li>Q1 sprint backlog
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/61f37570-3142-4a42-b3f0-d67db43dc332/image.png" alt=""></li>
</ol>
<p>==</p>
<ol start="7">
<li>Q2 sprint backlog
<img src="https://velog.velcdn.com/images/youngkyoo_kim/post/f2f04ab8-70ae-4766-8bca-ebc7037003c2/image.png" alt=""></li>
</ol>
<p>======</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A30a]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A30a</link>
            <guid>https://velog.io/@youngkyoo_kim/26A30a</guid>
            <pubDate>Wed, 29 Apr 2026 22:44:23 GMT</pubDate>
            <description><![CDATA[<p>Kubernetes에서 특정 NFS 경로를 사용하는 PV(PersistentVolume)를 필터링하는 가장 효율적인 방법은 kubectl의 출력 형식을 활용하거나 jq를 사용하는 것입니다.
상황에 맞는 몇 가지 명령어를 정리해 드립니다.</p>
<h3 id="1-custom-columns를-사용하여-가독성-있게-확인하기">1. custom-columns를 사용하여 가독성 있게 확인하기</h3>
<p>PV 이름과 NFS 경로를 열 형태로 출력한 뒤 grep으로 필터링하는 방식입니다. 경로가 정확히 무엇인지 확인하며 리스트업할 때 유용합니다.</p>
<pre><code class="language-bash">kubectl get pv -o custom-columns=&quot;NAME:.metadata.name,NFS_PATH:.spec.nfs.path,NFS_SERVER:.spec.nfs.server&quot; | grep &quot;/your/specific/path&quot;
</code></pre>
<h3 id="2-jsonpath를-사용하여-이름만-추출하기">2. jsonpath를 사용하여 이름만 추출하기</h3>
<p>특정 경로와 <strong>정확히 일치</strong>하는 PV의 이름만 깔끔하게 뽑아내고 싶을 때 사용합니다.</p>
<pre><code class="language-bash">kubectl get pv -o jsonpath=&#39;{range .items[?(@.spec.nfs.path==&quot;/your/specific/path&quot;)]}{.metadata.name}{&quot;\n&quot;}{end}&#39;
</code></pre>
<h3 id="3-jq를-사용한-정교한-필터링-추천">3. jq를 사용한 정교한 필터링 (추천)</h3>
<p>경로의 일부만 포함되어 있거나, NFS 서버 주소까지 함께 체크해야 하는 대규모 클러스터 환경에서는 jq가 가장 강력합니다.</p>
<ul>
<li><p><strong>특정 경로가 포함된 모든 PV 이름 출력:</strong></p>
<pre><code class="language-bash">kubectl get pv -o json | jq -r &#39;.items[] | select(.spec.nfs.path | contains(&quot;/your/specific/path&quot;)) | .metadata.name&#39;
</code></pre>
</li>
<li><p><strong>NFS 서버와 경로를 동시에 만족하는 경우:</strong></p>
<pre><code class="language-bash">kubectl get pv -o json | jq -r &#39;.items[] | select(.spec.nfs.path == &quot;/your/path&quot; and .spec.nfs.server == &quot;10.0.0.1&quot;) | .metadata.name&#39;
</code></pre>
<h3 id="4-고성능-클러스터를-위한-팁">4. 고성능 클러스터를 위한 팁</h3>
<p>만약 관리 중인 PV의 수가 수천 개 단위로 매우 많다면, 전체 리스트를 가져오는 과정에서 API 서버에 부하가 걸릴 수 있습니다. 이럴 때는 --go-template을 활용하여 클라이언트 사이드 부하를 줄일 수 있습니다.</p>
<pre><code class="language-bash">kubectl get pv -o go-template=&#39;{{range .items}}{{if .spec.nfs}}{{if eq .spec.nfs.path &quot;/your/specific/path&quot;}}{{.metadata.name}}{{&quot;\n&quot;}}{{end}}{{end}}{{end}}&#39;
</code></pre>
</li>
</ul>
<pre><code>위 명령어 중 본인의 작업 환경(정확한 일치 여부, 시인성 등)에 가장 적합한 것을 골라 사용하시면 됩니다. 구체적으로 찾으시는 경로 형식이 있다면 그에 맞춰 필터를 더 세분화해 드릴 수 있습니다.</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[26A29d]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A29d</link>
            <guid>https://velog.io/@youngkyoo_kim/26A29d</guid>
            <pubDate>Wed, 29 Apr 2026 07:18:36 GMT</pubDate>
            <description><![CDATA[<p>서버에서 특정 포트를 &quot;열어둔다&quot;는 것은 두 가지 단계가 필요합니다. 첫 번째는 <strong>해당 포트를 점유하여 대기하는 프로세스(리스너)</strong>를 띄우는 것이고, 두 번째는 <strong>OS 방화벽에서 해당 포트로의 접근을 허용</strong>하는 것입니다.</p>
<p>테스트용으로 빠르게 띄우는 방법과 방화벽 설정법을 정리해 드립니다.</p>
<hr>
<h3 id="1-테스트용-포트-리스너-띄우기-linux">1. 테스트용 포트 리스너 띄우기 (Linux)</h3>
<p>가장 간단한 방법은 <code>nc(netcat)</code>를 사용하는 것입니다. 도구가 없다면 <code>python</code>을 활용할 수도 있습니다.</p>
<h4 id="a-netcat-nc-사용"><strong>A. Netcat (nc) 사용</strong></h4>
<ul>
<li><strong>TCP 포트 열기:</strong><pre><code class="language-bash">  # -l: Listen, -p: Port
  nc -l -p 8080</code></pre>
</li>
<li><strong>UDP 포트 열기:</strong><pre><code class="language-bash">  # -u: UDP
  nc -lu -p 8080</code></pre>
<ul>
<li><em>팁: <code>ncat</code>(nmap 패키지)을 쓰신다면 <code>-k</code> 옵션을 추가해 클라이언트가 접속을 끊어도 계속 대기하게 할 수 있습니다.</em></li>
</ul>
</li>
</ul>
<h4 id="b-python-사용-별도-설치-없을-때"><strong>B. Python 사용 (별도 설치 없을 때)</strong></h4>
<ul>
<li><strong>TCP (간이 웹서버):</strong><pre><code class="language-bash">  python3 -m http.server 8080</code></pre>
</li>
<li><strong>UDP (스크립트 필요):</strong><pre><code class="language-python">  # udp_server.py
  import socket
  sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  sock.bind((&quot;0.0.0.0&quot;, 8080))
  print(&quot;UDP server listening on 8080...&quot;)
  while True:
      data, addr = sock.recvfrom(1024)
      print(f&quot;Received: {data} from {addr}&quot;)</code></pre>
</li>
</ul>
<hr>
<h3 id="2-os-방화벽에서-포트-허용하기">2. OS 방화벽에서 포트 허용하기</h3>
<p>리스너를 띄웠어도 방화벽에서 막히면 외부 접속이 안 됩니다. OS별 명령어가 다릅니다.</p>
<h4 id="a-rhelcentosrocky-firewalld"><strong>A. RHEL/CentOS/Rocky (firewalld)</strong></h4>
<pre><code class="language-bash"># TCP 8080 허용
sudo firewall-cmd --permanent --add-port=8080/tcp
# UDP 8080 허용
sudo firewall-cmd --permanent --add-port=8080/udp
# 적용
sudo firewall-cmd --reload</code></pre>
<h4 id="b-ubuntudebian-ufw"><strong>B. Ubuntu/Debian (ufw)</strong></h4>
<pre><code class="language-bash">sudo ufw allow 8080/tcp
sudo ufw allow 8080/udp</code></pre>
<h4 id="c-windows-powershell"><strong>C. Windows (PowerShell)</strong></h4>
<pre><code class="language-powershell"># TCP 8080 인바운드 규칙 추가
New-NetFirewallRule -DisplayName &quot;Test_TCP_8080&quot; -Direction Inbound -LocalPort 8080 -Protocol TCP -Action Allow

# UDP 8080 인바운드 규칙 추가
New-NetFirewallRule -DisplayName &quot;Test_UDP_8080&quot; -Direction Inbound -LocalPort 8080 -Protocol UDP -Action Allow</code></pre>
<hr>
<h3 id="3-제대로-떴는지-확인하는-법">3. 제대로 떴는지 확인하는 법</h3>
<p>서버 내부에서 아래 명령어로 내 프로세스가 포트를 잘 잡고 있는지 확인하세요.</p>
<pre><code class="language-bash"># TCP 리스닝 확인
ss -ntlp | grep 8080

# UDP 리스닝 확인
ss -nulp | grep 8080</code></pre>
<ul>
<li><strong><code>0.0.0.0:8080</code></strong>: 모든 인터페이스에서 접속 허용 상태</li>
<li><strong><code>127.0.0.1:8080</code></strong>: 서버 내부(Local)에서만 접속 가능 상태 (외부 접속 불가)</li>
</ul>
<hr>
<h3 id="💡-전문가의-팁">💡 전문가의 팁</h3>
<p>대규모 인프라(K8s 등)를 운영하신다면, OS 방화벽 외에도 <strong>클라우드 보안 그룹(Security Group)</strong>이나 <strong>네트워크 ACL</strong>, 혹은 쿠버네티스의 <strong>NetworkPolicy</strong>가 가로막고 있지는 않은지 반드시 확인해야 합니다. </p>
<p>특히 UDP의 경우, 일부 클라우드 로드밸런서(LB)나 방화벽 장비에서 기본적으로 차단되어 있거나 세션 유지 시간이 매우 짧게 설정된 경우가 많으니 이 점 유의하세요!</p>
<p>==</p>
<p>서버 간 네트워크 통신 가능 여부를 확인하는 것은 인프라 트러블슈팅의 기본입니다. 특히 에어갭(Air-gapped) 환경이나 폐쇄망을 관리하신다면, 별도의 도구 설치 없이 기본 명령어로 확인하는 방법이 매우 중요합니다.</p>
<p>주요 도구별 사용법을 정리해 드립니다.</p>
<hr>
<h3 id="1-linux-환경-netcat-nmap-bash">1. Linux 환경 (Netcat, Nmap, Bash)</h3>
<h4 id="a-nc-netcat---가장-범용적인-도구">A. nc (Netcat) - 가장 범용적인 도구</h4>
<p>대부분의 리눅스 배포판에 설치되어 있으며, TCP와 UDP 모두 지원합니다.</p>
<ul>
<li><strong>TCP 확인:</strong><pre><code class="language-bash">  # -z: 스캔 모드, -v: 상세 출력, -w: 타임아웃(초)
  nc -zv -w 3 &lt;IP주소&gt; &lt;포트번호&gt;</code></pre>
</li>
<li><strong>UDP 확인:</strong><pre><code class="language-bash">  # -u: UDP 모드
  nc -zuv -w 3 &lt;IP주소&gt; &lt;포트번호&gt;</code></pre>
<blockquote>
<p><strong>주의:</strong> UDP는 비연결형 프로토콜이라 포트가 열려 있어도 응답을 보내지 않는 경우가 많습니다. <code>succeeded!</code>가 뜨지 않더라도 방화벽에서 거부(<code>Refused</code> 또는 <code>Admin Prohibited</code>) 메시지가 오는지 확인해야 합니다.</p>
</blockquote>
</li>
</ul>
<h4 id="b-nmap---상세-스캔용">B. Nmap - 상세 스캔용</h4>
<p>네트워크 보안 및 포트 스캔의 표준 도구입니다.</p>
<ul>
<li><strong>TCP/UDP 동시 확인:</strong><pre><code class="language-bash">  # -sT: TCP 연결 스캔, -sU: UDP 스캔, -p: 포트 지정
  sudo nmap -sT -sU -p 80,443,53 &lt;IP주소&gt;</code></pre>
</li>
</ul>
<h4 id="c-bash-내장-기능-도구가-전혀-없을-때">C. Bash 내장 기능 (도구가 전혀 없을 때)</h4>
<p><code>nc</code>나 <code>telnet</code>이 설치되지 않은 최소 설치(Minimal) 환경에서 유용합니다.</p>
<ul>
<li><strong>TCP 확인:</strong><pre><code class="language-bash">  timeout 3 bash -c &quot;cat &lt; /dev/tcp/&lt;IP주소&gt;/&lt;포트번호&gt;&quot; &amp;&amp; echo &quot;Open&quot; || echo &quot;Closed&quot;</code></pre>
</li>
<li><strong>UDP 확인:</strong><pre><code class="language-bash">  timeout 3 bash -c &quot;cat &lt; /dev/udp/&lt;IP주소&gt;/&lt;포트번호&gt;&quot; &amp;&amp; echo &quot;Sent&quot; || echo &quot;Failed&quot;</code></pre>
</li>
</ul>
<hr>
<h3 id="2-windows-환경-powershell">2. Windows 환경 (PowerShell)</h3>
<p>Windows 서버 간 혹은 클라이언트에서 서버 접속 여부를 확인할 때 사용합니다.</p>
<h4 id="a-test-netconnection-tnc">A. Test-NetConnection (TNC)</h4>
<p>가장 강력하고 권장되는 명령어입니다.</p>
<ul>
<li><strong>TCP 확인:</strong><pre><code class="language-powershell">  Test-NetConnection -ComputerName &lt;IP주소&gt; -Port &lt;포트번호&gt;</code></pre>
<ul>
<li><code>TcpTestSucceeded : True</code>가 나오면 연결 성공입니다.</li>
</ul>
</li>
</ul>
<h4 id="b-powershell을-이용한-udp-확인">B. PowerShell을 이용한 UDP 확인</h4>
<p>PowerShell 기본 명령어에는 UDP 전용 테스트가 없으므로 .NET 클래스를 활용해야 합니다.</p>
<pre><code class="language-powershell">$udpClient = New-Object System.Net.Sockets.UdpClient
$udpClient.Connect(&quot;&lt;IP주소&gt;&quot;, &lt;포트번호&gt;)
# UDP는 데이터 전송 후 에러가 없으면 열려있다고 가정하는 경우가 많음</code></pre>
<hr>
<h3 id="3-트러블슈팅-팁-checklist">3. 트러블슈팅 팁 (Checklist)</h3>
<ol>
<li><strong>방화벽(iptables/firewalld/Security Group):</strong> 대상 서버에서 포트를 허용했는지뿐만 아니라, <strong>나가는(Outbound) 서버</strong>에서도 해당 포트가 막혀있지 않은지 확인하세요.</li>
<li><strong>리스닝(Listening) 상태:</strong> 접속이 안 된다면 대상 서버에서 실제 서비스가 해당 포트를 점유하고 있는지 먼저 확인해야 합니다.<ul>
<li><code>netstat -tulnp | grep &lt;포트번호&gt;</code></li>
<li><code>ss -tulnp | grep &lt;포트번호&gt;</code></li>
</ul>
</li>
<li><strong>MTU 및 경로 이슈:</strong> <code>ping</code>은 가는데 특정 서비스만 안 된다면, 특히 쿠버네티스 CNI나 VPN 환경일 경우 패킷 파편화(Fragmentation) 문제일 수 있습니다.</li>
<li><strong>UDP의 특성:</strong> UDP 테스트 시 &quot;Open&quot;이라고 떠도 실제 애플리케이션 응답이 없으면 방화벽에서 드랍(Drop) 중일 가능성이 큽니다. 이 경우 <code>tcpdump</code>로 패킷이 도달하는지 직접 찍어보는 것이 확실합니다.</li>
</ol>
<p>어느 도구를 사용하는 것이 현재 환경에 가장 적합하신가요? 상황에 맞는 스크립트가 필요하시면 바로 작성해 드릴 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A29c]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A29c</link>
            <guid>https://velog.io/@youngkyoo_kim/26A29c</guid>
            <pubDate>Tue, 28 Apr 2026 22:20:29 GMT</pubDate>
            <description><![CDATA[<p>전체 컨테이너화 + Nexus 등록 + K8s 배포 가이드를 작성했습니다.</p>
<hr>
<h2 id="흐름-요약">흐름 요약</h2>
<p><strong>1단계 — 로컬 PC에서 빌드</strong></p>
<p>프로젝트 루트에 <code>.env.build</code> 파일에 Nexus 주소와 인증정보를 설정한 뒤 <code>./build/build-all.sh v1.2</code>를 실행하면 5개 이미지(extractor, processor, indexer, git-tools, api-server)가 한 번에 빌드됩니다.</p>
<p><strong>2단계 — Nexus에 Push</strong></p>
<p><code>./build/push-all.sh v1.2</code>를 실행하면 Nexus Docker Registry에 버전 태그와 <code>latest</code> 태그가 함께 등록됩니다. Push 후 Nexus REST API로 등록 결과를 자동 확인합니다.</p>
<p><strong>3단계 — K8s 배포</strong></p>
<p>순서가 중요한데, <code>nexus-pull-secret</code> → <code>ServiceAccount/RBAC</code> → <code>Secrets</code> → <code>PVC</code> → <code>Argo CronWorkflow</code> 순으로 적용합니다. ServiceAccount에 imagePullSecret을 연결해두면 이후 모든 Pod가 자동으로 Nexus 인증을 사용합니다.</p>
<p><strong>중요 포인트 두 가지:</strong></p>
<p>첫째, 사내 Nexus가 자체 서명 인증서를 쓴다면 Docker daemon의 <code>insecure-registries</code>에 등록해야 빌드 머신에서 push가 됩니다. K8s 워커 노드의 containerd 설정에도 동일하게 추가해야 pull이 됩니다.</p>
<p>둘째, <code>processor</code> 이미지는 GPU(nvidia/cuda 베이스)를 쓰기 때문에 이미지 크기가 다른 것보다 훨씬 큽니다. 사내 빌드 머신에 GPU가 없어도 빌드는 가능하고, 실행 시에만 GPU 노드에 스케줄됩니다(<code>nodeSelector: accelerator: nvidia-gpu</code>).</p>
<p>===</p>
<h1 id="aiops-pipeline-컨테이너화--nexus-등록-전체-가이드">AIOps Pipeline 컨테이너화 &amp; Nexus 등록 전체 가이드</h1>
<blockquote>
<p><strong>환경 가정:</strong> 사내 Nexus Repository Manager 3.x / Docker Registry 포함 / K8s 내부망(Airgapped)</p>
</blockquote>
<hr>
<h2 id="1-전체-디렉토리-구조">1. 전체 디렉토리 구조</h2>
<p>먼저 아래와 같이 소스 파일들을 배치합니다.</p>
<pre><code>aiops-pipeline/                        ← 프로젝트 루트 (Git 저장소)
├── docker/
│   ├── extractor/
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   ├── processor/
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   ├── indexer/
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   ├── git-tools/
│   │   └── Dockerfile
│   └── api-server/
│       ├── Dockerfile
│       └── requirements.txt
│
├── scripts/                           ← 앞서 작성한 Python 소스
│   ├── incremental_export.py
│   ├── processor.py
│   ├── lifecycle_manager.py
│   ├── indexer.py
│   ├── aiops_agent.py
│   ├── api_server.py
│   └── sync_to_git.sh
│
├── k8s/
│   ├── secrets.yaml
│   ├── pvc.yaml
│   ├── argo-pipeline.yaml
│   ├── api-server-deployment.yaml
│   └── nexus-pull-secret.yaml
│
├── build/
│   ├── build-all.sh                   ← 전체 이미지 한번에 빌드
│   └── push-all.sh                    ← 전체 이미지 Nexus에 Push
│
└── .env.build                         ← 빌드용 환경변수 (Git에 올리지 않음)</code></pre><hr>
<h2 id="2-envbuild-빌드-환경-변수-설정">2. .env.build (빌드 환경 변수 설정)</h2>
<pre><code class="language-bash"># .env.build  ← .gitignore에 추가 필수
NEXUS_HOST=nexus.internal.company.com
NEXUS_REPO=docker-hosted          # Nexus Docker Hosted Repository 이름
NEXUS_PORT=8082                   # Nexus Docker Registry 포트
NEXUS_USER=deploy-user
NEXUS_PASS=your-nexus-password
IMAGE_TAG=v1.2                    # 배포 버전 태그</code></pre>
<hr>
<h2 id="3-dockerfile-작성">3. Dockerfile 작성</h2>
<h3 id="3-1-extractor-confluence-추출기">3-1. extractor (Confluence 추출기)</h3>
<pre><code class="language-dockerfile"># docker/extractor/Dockerfile
FROM python:3.11-slim AS base

LABEL maintainer=&quot;platform-team@company.com&quot;
LABEL version=&quot;1.2&quot;
LABEL description=&quot;Confluence Incremental Exporter&quot;

# 시스템 패키지 설치 (최소화)
RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    wget curl ca-certificates \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# MinIO Client 설치
RUN wget -q https://dl.min.io/client/mc/release/linux-amd64/mc \
    -O /usr/local/bin/mc &amp;&amp; chmod +x /usr/local/bin/mc

WORKDIR /app

# Python 의존성 먼저 복사 (캐시 레이어 활용)
COPY docker/extractor/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 소스 복사
COPY scripts/incremental_export.py .

# 비루트 사용자로 실행 (보안)
RUN useradd -m -u 1000 appuser &amp;&amp; chown -R appuser /app
USER appuser

ENTRYPOINT [&quot;python&quot;, &quot;incremental_export.py&quot;]</code></pre>
<pre><code class="language-text"># docker/extractor/requirements.txt
atlassian-python-api==3.41.11
html2text==2024.2.26
minio==7.2.7
requests==2.31.0
python-dotenv==1.0.0</code></pre>
<hr>
<h3 id="3-2-processor-llm-분류기---gpu-포함">3-2. processor (LLM 분류기 - GPU 포함)</h3>
<pre><code class="language-dockerfile"># docker/processor/Dockerfile
# GPU 사용: CUDA 12.1 + Python 3.11 베이스 이미지
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 AS base

LABEL description=&quot;LangChain LLM Document Processor&quot;

ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1

RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    python3.11 python3.11-dev python3-pip \
    wget curl git \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# python3.11을 기본 python으로 설정
RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 \
    &amp;&amp; update-alternatives --install /usr/bin/pip pip /usr/bin/pip3 1

# MinIO Client
RUN wget -q https://dl.min.io/client/mc/release/linux-amd64/mc \
    -O /usr/local/bin/mc &amp;&amp; chmod +x /usr/local/bin/mc

WORKDIR /app

COPY docker/processor/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 소스 복사
COPY scripts/processor.py .
COPY scripts/lifecycle_manager.py .

RUN useradd -m -u 1000 appuser &amp;&amp; chown -R appuser /app
USER appuser

ENTRYPOINT [&quot;python&quot;, &quot;processor.py&quot;]</code></pre>
<pre><code class="language-text"># docker/processor/requirements.txt
langchain==0.2.16
langchain-openai==0.1.23
langchain-community==0.2.16
langchain-core==0.2.38
langchain-huggingface==0.0.3
minio==7.2.7
pydantic==1.10.21
pyyaml==6.0.2
requests==2.31.0
# 폐쇄망: transformers/sentence-transformers는 모델과 함께 /models에 미리 반입</code></pre>
<hr>
<h3 id="3-3-indexer-vector-db-인덱서">3-3. indexer (Vector DB 인덱서)</h3>
<pre><code class="language-dockerfile"># docker/indexer/Dockerfile
FROM python:3.11-slim AS base

LABEL description=&quot;Milvus Vector DB Indexer&quot;

RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    git curl \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY docker/indexer/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY scripts/indexer.py .

RUN useradd -m -u 1000 appuser &amp;&amp; chown -R appuser /app
USER appuser

ENTRYPOINT [&quot;python&quot;, &quot;indexer.py&quot;]</code></pre>
<pre><code class="language-text"># docker/indexer/requirements.txt
langchain==0.2.16
langchain-community==0.2.16
langchain-huggingface==0.0.3
langchain-milvus==0.1.4
pymilvus==2.4.4
unstructured==0.14.10
pyyaml==6.0.2
sqlalchemy==2.0.34</code></pre>
<hr>
<h3 id="3-4-git-tools-git-동기화-도구">3-4. git-tools (Git 동기화 도구)</h3>
<pre><code class="language-dockerfile"># docker/git-tools/Dockerfile
FROM alpine:3.19 AS base

LABEL description=&quot;Git Sync Tool for Wiki Pipeline&quot;

# git, bash, python3, mc 설치
RUN apk add --no-cache \
    git bash curl wget python3 py3-pip \
    openssh-client

# MinIO Client
RUN wget -q https://dl.min.io/client/mc/release/linux-amd64/mc \
    -O /usr/local/bin/mc &amp;&amp; chmod +x /usr/local/bin/mc

# Python 패키지 (lifecycle_manager.py 의존성)
RUN pip3 install --no-cache-dir pyyaml requests

WORKDIR /app

COPY scripts/sync_to_git.sh .
COPY scripts/lifecycle_manager.py .
RUN chmod +x sync_to_git.sh

# git 설정 (컨테이너 내 기본값)
RUN git config --global http.sslVerify false &amp;&amp; \
    git config --global core.compression 0

ENTRYPOINT [&quot;/bin/bash&quot;, &quot;sync_to_git.sh&quot;]</code></pre>
<hr>
<h3 id="3-5-api-server-aiops-fastapi-서버">3-5. api-server (AIOps FastAPI 서버)</h3>
<pre><code class="language-dockerfile"># docker/api-server/Dockerfile
FROM python:3.11-slim AS base

LABEL description=&quot;AIOps LangGraph Agent API Server&quot;

RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    curl \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY docker/api-server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# K8s Python Client (클러스터 내부에서 실행)
RUN pip install --no-cache-dir kubernetes==30.1.0

COPY scripts/aiops_agent.py .
COPY scripts/api_server.py .

RUN useradd -m -u 1000 appuser &amp;&amp; chown -R appuser /app
USER appuser

EXPOSE 8080

# 헬스체크
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \
    CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT [&quot;uvicorn&quot;, &quot;api_server:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8080&quot;, &quot;--workers&quot;, &quot;2&quot;]</code></pre>
<pre><code class="language-text"># docker/api-server/requirements.txt
fastapi==0.111.1
uvicorn==0.30.3
langchain==0.2.16
langchain-openai==0.1.23
langchain-community==0.2.16
langchain-huggingface==0.0.3
langchain-milvus==0.1.4
langgraph==0.1.19
pymilvus==2.4.4
httpx==0.27.0
pydantic==2.8.2
pyyaml==6.0.2</code></pre>
<hr>
<h2 id="4-nexus-docker-registry-설정">4. Nexus Docker Registry 설정</h2>
<h3 id="4-1-nexus에서-docker-hosted-repository-확인생성">4-1. Nexus에서 Docker Hosted Repository 확인/생성</h3>
<p>Nexus 관리 UI에서 확인합니다.</p>
<pre><code>Nexus UI: http://nexus.internal.company.com:8081
메뉴: Repository → Repositories → Create repository
Type: docker (hosted)
이름: docker-hosted
HTTP Port: 8082        ← Docker push/pull에 사용할 포트
Allow anonymous: false  ← 인증 필수</code></pre><h3 id="4-2-로컬-docker-daemon에-nexus-insecure-registry-등록">4-2. 로컬 Docker daemon에 Nexus insecure registry 등록</h3>
<p>사내 Nexus가 자체 서명 인증서를 쓰는 경우(내부망 흔한 상황):</p>
<pre><code class="language-json">// /etc/docker/daemon.json  (Linux) 또는
// Docker Desktop → Settings → Docker Engine (Windows/Mac)
{
  &quot;insecure-registries&quot;: [
    &quot;nexus.internal.company.com:8082&quot;
  ],
  &quot;registry-mirrors&quot;: [],
  &quot;log-driver&quot;: &quot;json-file&quot;,
  &quot;log-opts&quot;: {
    &quot;max-size&quot;: &quot;100m&quot;,
    &quot;max-file&quot;: &quot;3&quot;
  }
}</code></pre>
<pre><code class="language-bash"># daemon.json 수정 후 Docker 재시작
sudo systemctl restart docker

# 확인
docker info | grep -A 5 &quot;Insecure Registries&quot;</code></pre>
<hr>
<h2 id="5-빌드--push-스크립트">5. 빌드 &amp; Push 스크립트</h2>
<h3 id="5-1-전체-빌드-스크립트-buildbuild-allsh">5-1. 전체 빌드 스크립트 (<code>build/build-all.sh</code>)</h3>
<pre><code class="language-bash">#!/bin/bash
# build/build-all.sh
# 사용법: ./build/build-all.sh [optional-tag]
# 예시:   ./build/build-all.sh v1.3

set -euo pipefail

# .env.build 로드
SCRIPT_DIR=&quot;$(cd &quot;$(dirname &quot;${BASH_SOURCE[0]}&quot;)&quot; &amp;&amp; pwd)&quot;
PROJECT_ROOT=&quot;$(dirname &quot;$SCRIPT_DIR&quot;)&quot;
source &quot;$PROJECT_ROOT/.env.build&quot;

# 태그 오버라이드 (인자가 있으면 사용)
TAG=&quot;${1:-$IMAGE_TAG}&quot;
REGISTRY=&quot;${NEXUS_HOST}:${NEXUS_PORT}&quot;

echo &quot;==================================================&quot;
echo &quot; AIOps Pipeline 이미지 빌드 시작&quot;
echo &quot; Registry : ${REGISTRY}/${NEXUS_REPO}&quot;
echo &quot; Tag      : ${TAG}&quot;
echo &quot; Build Dir: ${PROJECT_ROOT}&quot;
echo &quot;==================================================&quot;

# ── 이미지 목록 정의 ─────────────────────────────────────────
declare -A IMAGES=(
    [&quot;extractor&quot;]=&quot;docker/extractor&quot;
    [&quot;processor&quot;]=&quot;docker/processor&quot;
    [&quot;indexer&quot;]=&quot;docker/indexer&quot;
    [&quot;git-tools&quot;]=&quot;docker/git-tools&quot;
    [&quot;api-server&quot;]=&quot;docker/api-server&quot;
)

# ── Nexus 로그인 ──────────────────────────────────────────────
echo &quot;&quot;
echo &quot;▶ Nexus Docker Registry 로그인...&quot;
echo &quot;$NEXUS_PASS&quot; | docker login &quot;${REGISTRY}&quot; \
    --username &quot;$NEXUS_USER&quot; \
    --password-stdin
echo &quot;✅ 로그인 성공&quot;

# ── 각 이미지 빌드 ────────────────────────────────────────────
for IMAGE_NAME in &quot;${!IMAGES[@]}&quot;; do
    DOCKERFILE_DIR=&quot;${PROJECT_ROOT}/${IMAGES[$IMAGE_NAME]}&quot;
    FULL_TAG=&quot;${REGISTRY}/${NEXUS_REPO}/wiki-pipeline/${IMAGE_NAME}:${TAG}&quot;
    LATEST_TAG=&quot;${REGISTRY}/${NEXUS_REPO}/wiki-pipeline/${IMAGE_NAME}:latest&quot;

    echo &quot;&quot;
    echo &quot;▶ Building: ${IMAGE_NAME} (${FULL_TAG})&quot;
    echo &quot;--------------------------------------------------&quot;

    docker build \
        --no-cache \
        --build-arg BUILDTIME=&quot;$(date -u +&quot;%Y-%m-%dT%H:%M:%SZ&quot;)&quot; \
        --build-arg VERSION=&quot;${TAG}&quot; \
        --label &quot;build.version=${TAG}&quot; \
        --label &quot;build.date=$(date -u +&quot;%Y-%m-%d&quot;)&quot; \
        --file &quot;${DOCKERFILE_DIR}/Dockerfile&quot; \
        --tag &quot;${FULL_TAG}&quot; \
        --tag &quot;${LATEST_TAG}&quot; \
        &quot;${PROJECT_ROOT}&quot;   # 빌드 컨텍스트는 프로젝트 루트

    echo &quot;✅ Built: ${IMAGE_NAME}:${TAG}&quot;
done

echo &quot;&quot;
echo &quot;==================================================&quot;
echo &quot; 빌드 완료! Push하려면: ./build/push-all.sh ${TAG}&quot;
echo &quot;==================================================&quot;</code></pre>
<h3 id="5-2-전체-push-스크립트-buildpush-allsh">5-2. 전체 Push 스크립트 (<code>build/push-all.sh</code>)</h3>
<pre><code class="language-bash">#!/bin/bash
# build/push-all.sh
# 사용법: ./build/push-all.sh [optional-tag]

set -euo pipefail

SCRIPT_DIR=&quot;$(cd &quot;$(dirname &quot;${BASH_SOURCE[0]}&quot;)&quot; &amp;&amp; pwd)&quot;
PROJECT_ROOT=&quot;$(dirname &quot;$SCRIPT_DIR&quot;)&quot;
source &quot;$PROJECT_ROOT/.env.build&quot;

TAG=&quot;${1:-$IMAGE_TAG}&quot;
REGISTRY=&quot;${NEXUS_HOST}:${NEXUS_PORT}&quot;

declare -a IMAGE_NAMES=(&quot;extractor&quot; &quot;processor&quot; &quot;indexer&quot; &quot;git-tools&quot; &quot;api-server&quot;)

echo &quot;&quot;
echo &quot;▶ Nexus에 이미지 Push 시작 (Tag: ${TAG})&quot;

for IMAGE_NAME in &quot;${IMAGE_NAMES[@]}&quot;; do
    FULL_TAG=&quot;${REGISTRY}/${NEXUS_REPO}/wiki-pipeline/${IMAGE_NAME}:${TAG}&quot;
    LATEST_TAG=&quot;${REGISTRY}/${NEXUS_REPO}/wiki-pipeline/${IMAGE_NAME}:latest&quot;

    echo &quot;&quot;
    echo &quot;▶ Pushing: ${IMAGE_NAME}...&quot;
    docker push &quot;${FULL_TAG}&quot;
    docker push &quot;${LATEST_TAG}&quot;
    echo &quot;✅ Pushed: ${FULL_TAG}&quot;
done

echo &quot;&quot;
echo &quot;==================================================&quot;
echo &quot; Push 완료!&quot;
echo &quot;&quot;
echo &quot; 등록된 이미지 확인:&quot;
for NAME in &quot;${IMAGE_NAMES[@]}&quot;; do
    echo &quot;  ${REGISTRY}/${NEXUS_REPO}/wiki-pipeline/${NAME}:${TAG}&quot;
done
echo &quot;==================================================&quot;

# Nexus에서 이미지 목록 확인 (REST API)
echo &quot;&quot;
echo &quot;▶ Nexus에서 등록된 이미지 목록 조회...&quot;
curl -s -u &quot;${NEXUS_USER}:${NEXUS_PASS}&quot; \
    &quot;http://${NEXUS_HOST}:8081/service/rest/v1/components?repository=${NEXUS_REPO}&quot; \
    | python3 -c &quot;
import json,sys
data = json.load(sys.stdin)
for item in data.get(&#39;items&#39;,[]):
    print(f\&quot;  → {item.get(&#39;name&#39;)}:{item.get(&#39;version&#39;)}\&quot;)
&quot; 2&gt;/dev/null || echo &quot;  (Nexus REST API 조회 실패 - UI에서 직접 확인하세요)&quot;</code></pre>
<h3 id="5-3-빌드-실행-방법">5-3. 빌드 실행 방법</h3>
<pre><code class="language-bash"># 1. 스크립트 실행 권한 부여
chmod +x build/build-all.sh build/push-all.sh

# 2. 전체 이미지 빌드
./build/build-all.sh v1.2

# 3. Nexus에 Push
./build/push-all.sh v1.2

# ── 특정 이미지만 빌드/Push 하고 싶을 때 ──────────────────
REGISTRY=&quot;nexus.internal.company.com:8082&quot;
REPO=&quot;docker-hosted&quot;

# 단일 이미지 빌드
docker build \
  -f docker/processor/Dockerfile \
  -t ${REGISTRY}/${REPO}/wiki-pipeline/processor:v1.2 \
  .

# 단일 이미지 Push
docker push ${REGISTRY}/${REPO}/wiki-pipeline/processor:v1.2</code></pre>
<hr>
<h2 id="6-k8s에서-nexus-이미지-pull-설정">6. K8s에서 Nexus 이미지 Pull 설정</h2>
<p>K8s 클러스터에서 Nexus의 프라이빗 이미지를 Pull하려면 <strong>imagePullSecret</strong>이 필요합니다.</p>
<h3 id="6-1-imagepullsecret-생성">6-1. imagePullSecret 생성</h3>
<pre><code class="language-bash"># k8s 네임스페이스에 Nexus 인증 정보 등록
kubectl create secret docker-registry nexus-pull-secret \
  --namespace platform-ops \
  --docker-server=nexus.internal.company.com:8082 \
  --docker-username=deploy-user \
  --docker-password=your-nexus-password \
  --docker-email=platform@company.com

# 확인
kubectl get secret nexus-pull-secret -n platform-ops -o yaml</code></pre>
<pre><code class="language-yaml"># k8s/nexus-pull-secret.yaml (선언형 방식)
apiVersion: v1
kind: Secret
metadata:
  name: nexus-pull-secret
  namespace: platform-ops
type: kubernetes.io/dockerconfigjson
data:
  # base64 인코딩된 docker config
  # 생성: cat ~/.docker/config.json | base64 -w 0
  .dockerconfigjson: &lt;base64-encoded-docker-config&gt;</code></pre>
<h3 id="6-2-serviceaccount에-imagepullsecret-연결-전역-설정">6-2. ServiceAccount에 imagePullSecret 연결 (전역 설정)</h3>
<p>매번 Pod에 <code>imagePullSecrets</code>를 붙이는 대신, ServiceAccount에 연결하면 네임스페이스 내 모든 Pod에 자동 적용됩니다.</p>
<pre><code class="language-bash"># 기존 default ServiceAccount에 연결
kubectl patch serviceaccount argo-wiki-sa \
  -n platform-ops \
  -p &#39;{&quot;imagePullSecrets&quot;: [{&quot;name&quot;: &quot;nexus-pull-secret&quot;}]}&#39;

# 또는 새 ServiceAccount 생성 시 포함</code></pre>
<pre><code class="language-yaml"># k8s/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: argo-wiki-sa
  namespace: platform-ops
imagePullSecrets:
- name: nexus-pull-secret
---
# Argo Workflows가 파이프라인 Pod를 생성할 수 있도록 권한 부여
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: argo-wiki-role
  namespace: platform-ops
rules:
- apiGroups: [&quot;&quot;]
  resources: [&quot;pods&quot;, &quot;pods/log&quot;, &quot;configmaps&quot;, &quot;persistentvolumeclaims&quot;]
  verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
- apiGroups: [&quot;&quot;]
  resources: [&quot;events&quot;, &quot;nodes&quot;]
  verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;]
- apiGroups: [&quot;apps&quot;]
  resources: [&quot;deployments&quot;, &quot;daemonsets&quot;]
  verbs: [&quot;get&quot;, &quot;list&quot;]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: argo-wiki-rolebinding
  namespace: platform-ops
subjects:
- kind: ServiceAccount
  name: argo-wiki-sa
  namespace: platform-ops
roleRef:
  kind: Role
  name: argo-wiki-role
  apiGroup: rbac.authorization.k8s.io</code></pre>
<hr>
<h2 id="7-k8s-배포-순서-처음-설치">7. K8s 배포 순서 (처음 설치)</h2>
<p>아래 순서대로 적용하면 됩니다.</p>
<pre><code class="language-bash"># ── Step 0: 네임스페이스 및 기반 리소스 ──────────────────────
kubectl create namespace platform-ops

# Nexus Pull Secret
kubectl apply -f k8s/nexus-pull-secret.yaml

# ServiceAccount &amp; RBAC
kubectl apply -f k8s/serviceaccount.yaml

# ── Step 1: Secret (인증 정보) 등록 ──────────────────────────
# 실제 값으로 수정 후 적용
kubectl apply -f k8s/secrets.yaml -n platform-ops

# ── Step 2: 공유 볼륨 (PVC) 생성 ─────────────────────────────
kubectl apply -f k8s/pvc.yaml -n platform-ops

# ── Step 3: Argo Workflows 설치 (미설치 시) ──────────────────
kubectl create namespace argo
kubectl apply -n argo -f https://github.com/argoproj/argo-workflows/releases/latest/download/install.yaml
# 또는 폐쇄망에서는 Helm chart를 내려받아 설치

# ── Step 4: AIOps API 서버 배포 ──────────────────────────────
kubectl apply -f k8s/api-server-deployment.yaml -n platform-ops

# ── Step 5: Argo Workflows CronWorkflow 등록 ─────────────────
kubectl apply -f k8s/argo-pipeline.yaml -n platform-ops

# ── Step 6: 첫 실행 (수동 트리거) ────────────────────────────
# Argo CLI 또는 UI에서 즉시 실행
argo submit --from=cronworkflow/llm-wiki-pipeline -n platform-ops

# 실행 상태 확인
argo list -n platform-ops
argo get &lt;workflow-name&gt; -n platform-ops
argo logs &lt;workflow-name&gt; -n platform-ops</code></pre>
<hr>
<h2 id="8-k8s-리소스-yaml-전체">8. K8s 리소스 YAML 전체</h2>
<h3 id="8-1-pvc-설정-k8spvcyaml">8-1. PVC 설정 (<code>k8s/pvc.yaml</code>)</h3>
<pre><code class="language-yaml"># k8s/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wiki-pipeline-pvc
  namespace: platform-ops
spec:
  accessModes:
    - ReadWriteMany       # 여러 Pod가 동시 접근 가능 (NFS 또는 Ceph RBD 권장)
  storageClassName: ceph-rbd   # 사내 스토리지 클래스명으로 변경
  resources:
    requests:
      storage: 50Gi</code></pre>
<h3 id="8-2-aiops-api-서버-deployment-k8sapi-server-deploymentyaml">8-2. AIOps API 서버 Deployment (<code>k8s/api-server-deployment.yaml</code>)</h3>
<pre><code class="language-yaml"># k8s/api-server-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: aiops-api-server
  namespace: platform-ops
  labels:
    app: aiops-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: aiops-api
  template:
    metadata:
      labels:
        app: aiops-api
    spec:
      serviceAccountName: argo-wiki-sa
      imagePullSecrets:
      - name: nexus-pull-secret
      containers:
      - name: api-server
        image: nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/api-server:v1.2
        ports:
        - containerPort: 8080
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        env:
        - name: EMBEDDING_MODEL_PATH
          value: &quot;/models/bge-m3&quot;
        - name: MILVUS_HOST
          value: &quot;milvus.storage.svc.cluster.local&quot;
        volumeMounts:
        - name: models
          mountPath: /models
          readOnly: true
        resources:
          requests:
            cpu: &quot;500m&quot;
            memory: &quot;2Gi&quot;
          limits:
            cpu: &quot;2&quot;
            memory: &quot;4Gi&quot;
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5
      volumes:
      - name: models
        hostPath:
          path: /data/models    # 워커 노드에 미리 모델 파일 배치
---
apiVersion: v1
kind: Service
metadata:
  name: aiops-api-svc
  namespace: platform-ops
spec:
  selector:
    app: aiops-api
  ports:
  - port: 8080
    targetPort: 8080
  type: ClusterIP
---
# 외부(Lens 로컬 PC)에서 접근할 수 있도록 NodePort 또는 Ingress 추가
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: aiops-api-ingress
  namespace: platform-ops
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: aiops.internal.company.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: aiops-api-svc
            port:
              number: 8080</code></pre>
<h3 id="8-3-argo-cronworkflow-이미지-주소-업데이트-포함">8-3. Argo CronWorkflow (이미지 주소 업데이트 포함)</h3>
<pre><code class="language-yaml"># k8s/argo-pipeline.yaml (Nexus 이미지 주소 적용)
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
  name: llm-wiki-pipeline
  namespace: platform-ops
spec:
  schedule: &quot;0 2 * * *&quot;
  timezone: &quot;Asia/Seoul&quot;
  concurrencyPolicy: Forbid
  workflowSpec:
    serviceAccountName: argo-wiki-sa
    entrypoint: main-pipeline

    # 전역 imagePullSecrets 설정
    imagePullSecrets:
    - name: nexus-pull-secret

    volumes:
    - name: workspace
      persistentVolumeClaim:
        claimName: wiki-pipeline-pvc
    - name: models
      hostPath:
        path: /data/models

    templates:
    - name: main-pipeline
      dag:
        tasks:
        - name: step1-extract
          template: confluence-extractor
        - name: step2-process
          dependencies: [step1-extract]
          template: langchain-processor
        - name: step3-lifecycle
          dependencies: [step2-process]
          template: lifecycle-manager
        - name: step4-git-sync
          dependencies: [step3-lifecycle]
          template: git-syncer
        - name: step5-indexing
          dependencies: [step4-git-sync]
          template: vector-indexer
        - name: step6-notify
          dependencies: [step5-indexing]
          template: slack-notifier

    # ── Nexus 이미지 주소 적용 ────────────────────────────────
    - name: confluence-extractor
      container:
        image: nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/extractor:v1.2
        command: [&quot;python&quot;, &quot;incremental_export.py&quot;]
        resources:
          requests: {cpu: &quot;500m&quot;, memory: &quot;1Gi&quot;}
          limits:   {cpu: &quot;2&quot;,   memory: &quot;2Gi&quot;}
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    - name: langchain-processor
      container:
        image: nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/processor:v1.2
        command: [&quot;python&quot;, &quot;processor.py&quot;]
        resources:
          requests:
            cpu: &quot;2&quot;
            memory: &quot;8Gi&quot;
            nvidia.com/gpu: &quot;1&quot;
          limits:
            cpu: &quot;8&quot;
            memory: &quot;16Gi&quot;
            nvidia.com/gpu: &quot;1&quot;
        nodeSelector:
          accelerator: nvidia-gpu
        tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        env:
        - name: LLM_ENDPOINT
          value: &quot;http://vllm-service.ai-namespace.svc.cluster.local:8000/v1&quot;
        volumeMounts:
        - name: workspace
          mountPath: /workspace
        - name: models
          mountPath: /models
          readOnly: true

    - name: lifecycle-manager
      container:
        image: nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/processor:v1.2
        command: [&quot;python&quot;, &quot;lifecycle_manager.py&quot;, &quot;--action&quot;, &quot;archive&quot;,
                  &quot;--clone-dir&quot;, &quot;/workspace/llm-wiki&quot;]
        resources:
          requests: {cpu: &quot;200m&quot;, memory: &quot;512Mi&quot;}
          limits:   {cpu: &quot;1&quot;,   memory: &quot;1Gi&quot;}
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    - name: git-syncer
      container:
        image: nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/git-tools:v1.2
        command: [&quot;/bin/bash&quot;, &quot;sync_to_git.sh&quot;]
        resources:
          requests: {cpu: &quot;200m&quot;, memory: &quot;256Mi&quot;}
          limits:   {cpu: &quot;1&quot;,   memory: &quot;512Mi&quot;}
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    - name: vector-indexer
      container:
        image: nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/indexer:v1.2
        command: [&quot;python&quot;, &quot;indexer.py&quot;]
        resources:
          requests: {cpu: &quot;1&quot;,  memory: &quot;4Gi&quot;}
          limits:   {cpu: &quot;4&quot;,  memory: &quot;8Gi&quot;}
        env:
        - name: WIKI_DIR
          value: &quot;/workspace/llm-wiki/wiki/active&quot;
        - name: EMBEDDING_MODEL_PATH
          value: &quot;/models/bge-m3&quot;
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace
        - name: models
          mountPath: /models
          readOnly: true

    - name: slack-notifier
      container:
        image: curlimages/curl:latest    # 이건 Nexus에 미러링 후 주소 변경 권장
        command: [sh, -c]
        args:
        - |
          DATE=$(date &#39;+%Y-%m-%d %H:%M KST&#39;)
          curl -s -X POST &quot;$SLACK_WEBHOOK_URL&quot; \
            -H &#39;Content-type: application/json&#39; \
            -d &quot;{\&quot;text\&quot;:\&quot;✅ LLM Wiki 파이프라인 완료 (${DATE})\&quot;}&quot;
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets</code></pre>
<hr>
<h2 id="9-전체-실행-흐름-요약">9. 전체 실행 흐름 요약</h2>
<pre><code>[개발자 로컬 PC]
    1. 소스 수정 (scripts/*.py, docker/*/Dockerfile)
    2. ./build/build-all.sh v1.3      ← 이미지 빌드
    3. ./build/push-all.sh v1.3       ← Nexus에 Push

[Nexus Repository]
    4. 이미지 저장
       nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/extractor:v1.3
       nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/processor:v1.3
       ...

[K8s 클러스터]
    5. argo-pipeline.yaml의 image 주소를 v1.3으로 수정 후 apply
       kubectl apply -f k8s/argo-pipeline.yaml -n platform-ops

    6. Argo가 매일 새벽 2시에 자동 실행 (또는 수동 트리거)
       argo submit --from=cronworkflow/llm-wiki-pipeline -n platform-ops

    7. 각 Step Pod가 Nexus에서 이미지 Pull → 실행
       step1: Confluence 추출 → MinIO
       step2: LLM 분류 → MinIO wiki/
       step3: 생애주기 관리
       step4: Git Push
       step5: Milvus 인덱싱
       step6: Slack 알림

[AIOps API 서버] (24시간 상시 실행)
    - Alertmanager Webhook 수신
    - Lens에서 HTTP 호출: http://aiops.internal.company.com/analyze
    - 정기 리스크 스캔: GET /risk-scan</code></pre><hr>
<h2 id="10-트러블슈팅-체크리스트">10. 트러블슈팅 체크리스트</h2>
<pre><code class="language-bash"># 이미지 Pull 실패 시
kubectl describe pod &lt;pod-name&gt; -n platform-ops | grep -A 10 &quot;Events&quot;
# → ImagePullBackOff: nexus-pull-secret 확인
kubectl get secret nexus-pull-secret -n platform-ops

# Nexus 연결 확인
curl -u deploy-user:password \
  http://nexus.internal.company.com:8082/v2/_catalog

# 이미지 목록 확인
curl -u deploy-user:password \
  http://nexus.internal.company.com:8082/v2/docker-hosted/wiki-pipeline/extractor/tags/list

# Argo 파이프라인 로그 확인
argo logs llm-wiki-pipeline-&lt;hash&gt; -n platform-ops --follow

# 특정 Step 로그만 확인
argo logs llm-wiki-pipeline-&lt;hash&gt; -n platform-ops -c langchain-processor

# Pod에 직접 접속하여 디버깅
kubectl run debug-pod --rm -it \
  --image=nexus.internal.company.com:8082/docker-hosted/wiki-pipeline/processor:v1.2 \
  --overrides=&#39;{&quot;spec&quot;:{&quot;imagePullSecrets&quot;:[{&quot;name&quot;:&quot;nexus-pull-secret&quot;}]}}&#39; \
  -n platform-ops -- /bin/bash</code></pre>
<hr>
<p><em>빌드 가이드 버전: v1.0 | 작성일: 2026-04-29</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A29b]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A29b</link>
            <guid>https://velog.io/@youngkyoo_kim/26A29b</guid>
            <pubDate>Tue, 28 Apr 2026 22:12:13 GMT</pubDate>
            <description><![CDATA[<p>규모가 크고 중요한 설계 문서이므로, 전체 아키텍처와 핵심 코드를 포함한 단일 마크다운 파일로 작성합니다.전체 아키텍처 설계서와 핵심 코드를 담은 문서를 작성했습니다.</p>
<hr>
<h2 id="문서-구성-요약">문서 구성 요약</h2>
<p><strong>8개 핵심 코드 모듈</strong>이 포함되어 있으며, 각각의 역할과 연결 구조는 다음과 같습니다.</p>
<p><strong>데이터 수집 → 정제 → 저장 흐름:</strong></p>
<ul>
<li><code>incremental_export.py</code> — Confluence CQL 증분 추출, 파일 해시 비교로 중복 방지, MinIO <code>raw/</code> 저장</li>
<li><code>processor.py</code> — LangChain + 로컬 LLM(vLLM)으로 분류·링크수정·YAML 메타데이터 자동 생성, 중복 문서 감지 및 deprecated 처리</li>
<li><code>sync_to_git.sh</code> — MinIO <code>wiki/</code> → Git 동기화, lifecycle_manager 연계 호출</li>
<li><code>lifecycle_manager.py</code> — 180일 미검증 시 Slack 알림, 365일 경과 시 <code>archive/</code> 자동 이동</li>
</ul>
<p><strong>인덱싱 및 검색:</strong></p>
<ul>
<li><code>indexer.py</code> — <code>RecordManager</code>로 증분 인덱싱(변경분만), 삭제된 문서 자동 정리, Milvus Hybrid Search</li>
</ul>
<p><strong>AIOps 모니터링:</strong></p>
<ul>
<li><code>aiops_agent.py</code> — LangGraph 6-Node 워크플로우. 신뢰도 낮으면 자동 재검색 루프, K8s 실시간 컨텍스트 수집, 근본 원인 분석 → 해결책 → 리스크 예측까지 순차 실행</li>
<li><code>api_server.py</code> — FastAPI 서버. Alertmanager Webhook 수신, Lens에서 HTTP 호출, 정기 리스크 스캔 엔드포인트 제공</li>
<li><code>argo-pipeline.yaml</code> — 전체 6단계 파이프라인을 CronWorkflow로 오케스트레이션</li>
</ul>
<p><strong>생애주기 관리</strong>는 YAML <code>status</code> 필드(<code>draft → active → deprecated → archive</code>)를 중심으로 전 단계에 걸쳐 반영했고, GitLab 전환 대비 <code>.gitlab-ci.yml</code>도 포함되어 있습니다.</p>
<p>===</p>
<h1 id="aiops-platform--llm-wiki-전체-아키텍처-설계서">AIOps Platform + LLM Wiki 전체 아키텍처 설계서</h1>
<blockquote>
<p><strong>대상 환경:</strong> K8s 1,000노드 클러스터 / Cilium / MinIO AIStor / Airgapped / Bitbucket → GitLab 전환 예정<br><strong>목표:</strong> Confluence 문서를 LLM Wiki로 자동 변환하고, 실시간 K8s 상태와 결합하여 장애 원인 분석·해결책 제시·리스크 예측이 가능한 완전 자동화 AIOps 시스템 구축</p>
</blockquote>
<hr>
<h2 id="1-전체-시스템-아키텍처-high-level-overview">1. 전체 시스템 아키텍처 (High-Level Overview)</h2>
<pre><code>┌─────────────────────────────────────────────────────────────────────────┐
│                        DATA INGESTION LAYER                             │
│  Confluence  ──►  K8s CronJob(증분추출)  ──►  MinIO AIStor [raw/]      │
│  Web Vendor Docs ──► Firecrawl(Self-hosted) ──► MinIO AIStor [raw/]    │
└──────────────────────────────┬──────────────────────────────────────────┘
                               │ Argo Workflows (Event-Driven)
┌──────────────────────────────▼──────────────────────────────────────────┐
│                     INTELLIGENCE PROCESSING LAYER                       │
│  MinIO [raw/]  ──►  LangChain Processor                                │
│                     ├─ Link Resolver (Internal Links 수정)              │
│                     ├─ LLM Classifier (vLLM/Ollama - 폐쇄망)           │
│                     ├─ Metadata YAML 자동 생성                          │
│                     └─ Lifecycle Status 관리                            │
└──────────────────────────────┬──────────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────────┐
│                        KNOWLEDGE STORAGE LAYER                          │
│  Git (Bitbucket/GitLab)                                                 │
│  ├─ /raw/         ← MinIO에서 가져온 원본 MD                            │
│  └─ /wiki/        ← LLM이 정제한 구조화 지식                            │
│      ├─ /active/Architecture, SOP, Library, Reports                    │
│      └─ /archive/ ← 폐기된 구버전 문서                                  │
└──────────┬──────────────────────────────────────────────────────────────┘
           │ GitHub Actions / GitLab CI  (wiki/** push trigger)
┌──────────▼──────────────────────────────────────────────────────────────┐
│                         VECTOR INDEX LAYER                              │
│  LangChain Indexing API + RecordManager ──► Milvus (K8s Operator)      │
│  ├─ Hybrid Search: BM25(Keyword) + Vector(Semantic)                     │
│  └─ Metadata Filter: status, category, tech_stack, last_verified       │
└──────────┬──────────────────────────────────────────────────────────────┘
           │
┌──────────▼──────────────────────────────────────────────────────────────┐
│                        AIOPS MONITORING LAYER                           │
│  K8s 실시간 이벤트 (Prometheus/Alertmanager/K8s API)                   │
│      ──► LangGraph Agent                                                │
│           ├─ Node 1: 이벤트 수집 &amp; 증상 분석                            │
│           ├─ Node 2: RAG 검색 (Milvus - 관련 SOP/Library)              │
│           ├─ Node 3: 근본 원인 분석 (Root Cause Analysis)              │
│           ├─ Node 4: 해결책 생성 &amp; 검증                                 │
│           └─ Node 5: 리스크 예측 (Proactive Risk Detection)            │
│      ──► Lens (로컬 PC) + 사내 LLM API 연동                            │
└─────────────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="2-문서-생애주기-document-lifecycle-설계">2. 문서 생애주기 (Document Lifecycle) 설계</h2>
<p>모든 문서는 아래 4단계 상태를 가지며, LLM과 자동화 파이프라인이 상태를 관리합니다.</p>
<pre><code>draft ──► active ──► deprecated ──► archive
                         │
                   (180일 미검증 시 자동 알림)
                         │
                   (365일 후 archive 자동 이동)</code></pre><h3 id="표준-metadata-yaml-스키마">표준 Metadata YAML 스키마</h3>
<pre><code class="language-yaml">---
# === 식별 및 분류 ===
id: &quot;SOP-CIL-001&quot;                         # 문서 고유 ID (자동 생성)
title: &quot;Cilium BGP Control Plane 장애 대응&quot;
category: &quot;SOP&quot;                           # SOP | Library | Architecture | Reports
tech_stack: [&quot;Cilium&quot;, &quot;BGP&quot;, &quot;K8s&quot;]
sub_category: &quot;Network&quot;

# === 생애주기 관리 (Lifecycle) ===
status: &quot;active&quot;                          # draft | active | deprecated | archive
created_at: &quot;2026-01-15&quot;
last_verified_at: &quot;2026-04-28&quot;            # 기술 검증일 (180일 미갱신 시 알림)
verified_by: &quot;platform-lead&quot;
applies_to_version: &quot;cilium&gt;=1.15&quot;        # 유효 버전 범위
expires_at: null                          # 명시적 만료일 (null이면 자동 관리)

# === 출처 및 추적 ===
source: &quot;confluence&quot;                      # confluence | web | manual | ai-generated
source_url: &quot;https://confluence.internal/pages/12345&quot;
auto_classified: true
pipeline_version: &quot;v1.2&quot;

# === 운영 맥락 (AIOps) ===
severity: &quot;Critical&quot;                      # Critical | High | Medium | Low
environment: [&quot;production&quot;, &quot;staging&quot;]
target_audience: &quot;platform-engineer&quot;
contains_code: true
code_languages: [&quot;yaml&quot;, &quot;shell&quot;]
related_docs: [&quot;ARCH-NET-002&quot;, &quot;LIB-CIL-005&quot;]

# === 검색 최적화 ===
tags: [&quot;bgp-peering&quot;, &quot;network-failure&quot;, &quot;cilium&quot;, &quot;troubleshooting&quot;]
summary: &quot;Cilium BGP 피어링 실패 시 원인 진단 및 단계별 복구 절차&quot;
---</code></pre>
<hr>
<h2 id="3-핵심-코드-구현">3. 핵심 코드 구현</h2>
<h3 id="3-1-confluence-증분-추출-스크립트-incremental_exportpy">3-1. Confluence 증분 추출 스크립트 (<code>incremental_export.py</code>)</h3>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
Confluence 24시간 증분 추출 → MinIO AIStor 저장
K8s CronJob에서 매일 새벽 02:00 실행
&quot;&quot;&quot;
import os
import datetime
import hashlib
import json
from atlassian import Confluence
import html2text
from minio import Minio

# ── 환경 변수 ──────────────────────────────────────────────
CONFLUENCE_URL  = os.getenv(&quot;CONFLUENCE_URL&quot;)
CONFLUENCE_USER = os.getenv(&quot;CONFLUENCE_USER&quot;)
CONFLUENCE_TOKEN= os.getenv(&quot;CONFLUENCE_TOKEN&quot;)
PARENT_PAGE_ID  = os.getenv(&quot;PARENT_PAGE_ID&quot;)
MINIO_URL       = os.getenv(&quot;MINIO_URL&quot;)
MINIO_ACCESS    = os.getenv(&quot;MINIO_ACCESS_KEY&quot;)
MINIO_SECRET    = os.getenv(&quot;MINIO_SECRET_KEY&quot;)
BUCKET_NAME     = &quot;confluence-wiki&quot;
EXPORT_DIR      = &quot;/tmp/confluence_export&quot;
HASH_STORE_PATH = &quot;/tmp/hash_store.json&quot;

confluence = Confluence(url=CONFLUENCE_URL, username=CONFLUENCE_USER, password=CONFLUENCE_TOKEN)
minio_client = Minio(MINIO_URL, access_key=MINIO_ACCESS, secret_key=MINIO_SECRET, secure=True)

h = html2text.HTML2Text()
h.ignore_links = False
h.body_width = 0  # 줄바꿈 없이 원형 유지

def load_hash_store():
    &quot;&quot;&quot;이전 실행의 파일 해시값 로드 (중복 처리 방지)&quot;&quot;&quot;
    try:
        resp = minio_client.get_object(BUCKET_NAME, &quot;metadata/hash_store.json&quot;)
        return json.loads(resp.read())
    except Exception:
        return {}

def save_hash_store(store: dict):
    data = json.dumps(store).encode()
    from io import BytesIO
    minio_client.put_object(BUCKET_NAME, &quot;metadata/hash_store.json&quot;, BytesIO(data), len(data))

def extract_page(page_id: str, title: str) -&gt; str:
    &quot;&quot;&quot;Confluence 페이지 → Markdown 변환&quot;&quot;&quot;
    detail = confluence.get_page_by_id(page_id, expand=&#39;body.storage,version,ancestors&#39;)
    html_body = detail[&#39;body&#39;][&#39;storage&#39;][&#39;value&#39;]
    version = detail[&#39;version&#39;][&#39;number&#39;]

    # 첨부 이미지 수집
    attachments = confluence.get_attachments_from_content(page_id)
    attachment_map = {}
    for att in attachments.get(&#39;results&#39;, []):
        att_title = att[&#39;title&#39;]
        att_url = f&quot;{CONFLUENCE_URL}{att[&#39;_links&#39;][&#39;download&#39;]}&quot;
        attachment_map[att_title] = f&quot;./assets/{att_title}&quot;

    md_content = h.handle(html_body)

    # 첨부 이미지 경로를 상대 경로로 교체
    for original_url, relative_path in attachment_map.items():
        md_content = md_content.replace(original_url, relative_path)

    # YAML Frontmatter 초안 삽입 (LLM이 나중에 완성)
    safe_title = title.replace(&quot;/&quot;, &quot;-&quot;).replace(&quot; &quot;, &quot;-&quot;).lower()
    frontmatter = f&quot;&quot;&quot;---
id: &quot;PENDING-{page_id}&quot;
title: &quot;{title}&quot;
source: &quot;confluence&quot;
source_page_id: &quot;{page_id}&quot;
source_version: {version}
status: &quot;draft&quot;
auto_classified: false
created_at: &quot;{datetime.date.today()}&quot;
last_verified_at: &quot;{datetime.date.today()}&quot;
---

&quot;&quot;&quot;
    return frontmatter + md_content

def run_incremental_export():
    os.makedirs(EXPORT_DIR, exist_ok=True)
    hash_store = load_hash_store()

    # 24시간 이내 변경 페이지만 CQL로 추출
    cql = f&#39;ancestor = {PARENT_PAGE_ID} AND lastModified &gt;= now(&quot;-1d&quot;) ORDER BY lastModified DESC&#39;
    results = confluence.cql(cql).get(&#39;results&#39;, [])
    print(f&quot;[INFO] Found {len(results)} updated pages in last 24h&quot;)

    exported_count = 0
    for item in results:
        page = item.get(&#39;content&#39;, {})
        page_id = page.get(&#39;id&#39;)
        title = page.get(&#39;title&#39;, &#39;untitled&#39;).replace(&quot;/&quot;, &quot;-&quot;)

        try:
            md_content = extract_page(page_id, title)
            content_hash = hashlib.sha256(md_content.encode()).hexdigest()

            # 해시 비교 → 변경된 파일만 처리
            if hash_store.get(page_id) == content_hash:
                print(f&quot;[SKIP] No change: {title}&quot;)
                continue

            # MinIO raw/ 버킷에 저장 (날짜별 경로)
            date_prefix = datetime.date.today().strftime(&quot;%Y-%m-%d&quot;)
            minio_path = f&quot;raw/{date_prefix}/{title}.md&quot;
            data = md_content.encode(&#39;utf-8&#39;)
            from io import BytesIO
            minio_client.put_object(BUCKET_NAME, minio_path, BytesIO(data), len(data),
                                    content_type=&quot;text/markdown&quot;)

            hash_store[page_id] = content_hash
            exported_count += 1
            print(f&quot;[OK] Exported: {title} → {minio_path}&quot;)

        except Exception as e:
            print(f&quot;[ERROR] Failed to export {title}: {e}&quot;)

    save_hash_store(hash_store)
    print(f&quot;[DONE] Exported {exported_count} pages to MinIO&quot;)

if __name__ == &quot;__main__&quot;:
    run_incremental_export()</code></pre>
<hr>
<h3 id="3-2-langchain-지능형-분류-처리기-processorpy">3-2. LangChain 지능형 분류 처리기 (<code>processor.py</code>)</h3>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
MinIO raw/ → LLM 분류/링크수정/메타데이터 생성 → wiki/ 폴더 구조 출력
Argo Workflows langchain-processor Pod 내부에서 실행
&quot;&quot;&quot;
import os
import re
import json
import uuid
import datetime
from minio import Minio
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
from typing import List, Optional
import yaml

MINIO_URL    = os.getenv(&quot;MINIO_URL&quot;)
MINIO_ACCESS = os.getenv(&quot;MINIO_ACCESS_KEY&quot;)
MINIO_SECRET = os.getenv(&quot;MINIO_SECRET_KEY&quot;)
LLM_ENDPOINT = os.getenv(&quot;LLM_ENDPOINT&quot;, &quot;http://vllm-service.ai:8000/v1&quot;)
BUCKET_NAME  = &quot;confluence-wiki&quot;

minio_client = Minio(MINIO_URL, access_key=MINIO_ACCESS, secret_key=MINIO_SECRET, secure=True)

# 폐쇄망: vLLM이 OpenAI 호환 API 제공
llm = ChatOpenAI(
    model=&quot;llama-3-70b-instruct&quot;,
    base_url=LLM_ENDPOINT,
    api_key=&quot;internal&quot;,
    temperature=0
)

# ── Pydantic 출력 스키마 ─────────────────────────────────────
class DocMetadata(BaseModel):
    id_suffix: str = Field(description=&quot;문서 고유 식별자 접미사 (영문 대문자 3자리, 예: NET, K8S, MIO)&quot;)
    category: str = Field(description=&quot;SOP | Library | Architecture | Reports&quot;)
    sub_category: str = Field(description=&quot;Network | Storage | Compute | Security | Monitoring&quot;)
    tech_stack: List[str] = Field(description=&quot;관련 기술 스택 리스트 (예: [&#39;Cilium&#39;,&#39;BGP&#39;,&#39;K8s&#39;])&quot;)
    severity: str = Field(description=&quot;Critical | High | Medium | Low&quot;)
    summary: str = Field(description=&quot;문서 핵심 내용 한 문장 요약 (한국어)&quot;)
    tags: List[str] = Field(description=&quot;검색용 핵심 키워드 리스트 (소문자, kebab-case)&quot;)
    applies_to_version: Optional[str] = Field(description=&quot;유효 버전 (예: &#39;cilium&gt;=1.15&#39;), 없으면 null&quot;)
    contains_code: bool = Field(description=&quot;코드 블록 포함 여부&quot;)
    code_languages: List[str] = Field(description=&quot;포함된 코드 언어 리스트&quot;)
    optimized_filename: str = Field(description=&quot;파일명 (영문 kebab-case, .md 제외)&quot;)
    related_concepts: List[str] = Field(description=&quot;관련 인프라 컴포넌트 또는 개념 리스트&quot;)

structured_llm = llm.with_structured_output(DocMetadata)

# ── 내부 링크 리졸버 ──────────────────────────────────────────
def resolve_internal_links(content: str, all_filenames: list) -&gt; str:
    &quot;&quot;&quot;[[WikiLink]] → [WikiLink](./WikiLink.md) 변환 (파일 존재 확인 포함)&quot;&quot;&quot;
    def replace_link(match):
        link_text = match.group(1)
        safe_name = link_text.replace(&quot; &quot;, &quot;-&quot;).lower()
        # 파일 목록에서 존재 여부 확인
        matched = next((f for f in all_filenames if safe_name in f.lower()), None)
        if matched:
            return f&quot;[{link_text}](./{matched})&quot;
        return f&quot;[{link_text}](./{safe_name}.md)&quot;  # 미래 생성 파일 대비

    # [[Link|Alias]] 형식도 처리
    content = re.sub(r&#39;\[\[([^|\]]+)\|([^\]]+)\]\]&#39;, 
                     lambda m: f&quot;[{m.group(2)}](./{m.group(1).replace(&#39; &#39;,&#39;-&#39;).lower()}.md)&quot;, 
                     content)
    # [[Link]] 형식 처리
    content = re.sub(r&#39;\[\[([^\]]+)\]\]&#39;, replace_link, content)
    return content

# ── 중복 문서 감지 ────────────────────────────────────────────
def check_duplicate_in_wiki(new_content: str, wiki_path: str) -&gt; Optional[str]:
    &quot;&quot;&quot;기존 wiki 파일과 의미적 유사도 체크 (간단 버전: 제목+핵심키워드 비교)&quot;&quot;&quot;
    try:
        objects = minio_client.list_objects(BUCKET_NAME, prefix=&quot;wiki/active/&quot;, recursive=True)
        new_words = set(re.findall(r&#39;\b[A-Za-z]{4,}\b&#39;, new_content[:2000]))

        for obj in objects:
            if not obj.object_name.endswith(&#39;.md&#39;):
                continue
            resp = minio_client.get_object(BUCKET_NAME, obj.object_name)
            existing = resp.read().decode(&#39;utf-8&#39;)
            existing_words = set(re.findall(r&#39;\b[A-Za-z]{4,}\b&#39;, existing[:2000]))

            # 70% 이상 단어 겹침 → 중복 의심
            if len(new_words) &gt; 0:
                overlap = len(new_words &amp; existing_words) / len(new_words | existing_words)
                if overlap &gt; 0.7:
                    return obj.object_name
    except Exception:
        pass
    return None

# ── 메인 분류 프로세서 ────────────────────────────────────────
def process_raw_documents():
    &quot;&quot;&quot;MinIO raw/[today]/ 의 모든 MD 파일을 처리하여 wiki/active/ 로 저장&quot;&quot;&quot;
    today = datetime.date.today().strftime(&quot;%Y-%m-%d&quot;)
    prefix = f&quot;raw/{today}/&quot;

    objects = list(minio_client.list_objects(BUCKET_NAME, prefix=prefix, recursive=True))
    all_filenames = [os.path.basename(obj.object_name) for obj in objects]
    print(f&quot;[INFO] Processing {len(objects)} files from {prefix}&quot;)

    category_counter = {&quot;SOP&quot;: 0, &quot;Library&quot;: 0, &quot;Architecture&quot;: 0, &quot;Reports&quot;: 0}

    for obj in objects:
        if not obj.object_name.endswith(&#39;.md&#39;):
            continue

        try:
            # 1. 파일 읽기
            resp = minio_client.get_object(BUCKET_NAME, obj.object_name)
            raw_content = resp.read().decode(&#39;utf-8&#39;)

            # 기존 YAML Frontmatter 제거 (재생성 예정)
            body = re.sub(r&#39;^---\n.*?\n---\n&#39;, &#39;&#39;, raw_content, flags=re.DOTALL).strip()

            # 2. 내부 링크 수정
            refined = resolve_internal_links(body, all_filenames)

            # 3. LLM 메타데이터 분류
            prompt = ChatPromptTemplate.from_template(&quot;&quot;&quot;
당신은 플랫폼 엔지니어링 지식 관리 전문가입니다.
다음 인프라 기술 문서를 분석하여 정확한 메타데이터를 추출하세요.
문서 컨텍스트: K8s 1,000노드 클러스터, Cilium CNI, MinIO AIStor 환경

문서 내용 (앞 2000자):
{content}
&quot;&quot;&quot;)
            chain = prompt | structured_llm
            meta: DocMetadata = chain.invoke({&quot;content&quot;: refined[:2000]})

            # 4. 카테고리별 ID 생성
            cat_short = {&quot;SOP&quot;: &quot;SOP&quot;, &quot;Library&quot;: &quot;LIB&quot;, &quot;Architecture&quot;: &quot;ARCH&quot;, &quot;Reports&quot;: &quot;RPT&quot;}
            cat_key = cat_short.get(meta.category, &quot;DOC&quot;)
            category_counter[meta.category] = category_counter.get(meta.category, 0) + 1
            doc_id = f&quot;{cat_key}-{meta.id_suffix}-{category_counter.get(meta.category, 1):03d}&quot;

            # 5. 완전한 YAML Frontmatter 생성
            frontmatter = {
                &quot;id&quot;: doc_id,
                &quot;title&quot;: meta.optimized_filename.replace(&quot;-&quot;, &quot; &quot;).title(),
                &quot;category&quot;: meta.category,
                &quot;sub_category&quot;: meta.sub_category,
                &quot;tech_stack&quot;: meta.tech_stack,
                &quot;status&quot;: &quot;active&quot;,
                &quot;severity&quot;: meta.severity,
                &quot;summary&quot;: meta.summary,
                &quot;tags&quot;: meta.tags,
                &quot;applies_to_version&quot;: meta.applies_to_version,
                &quot;contains_code&quot;: meta.contains_code,
                &quot;code_languages&quot;: meta.code_languages,
                &quot;related_docs&quot;: [],
                &quot;related_concepts&quot;: meta.related_concepts,
                &quot;source&quot;: &quot;confluence&quot;,
                &quot;auto_classified&quot;: True,
                &quot;created_at&quot;: str(datetime.date.today()),
                &quot;last_verified_at&quot;: str(datetime.date.today()),
                &quot;verified_by&quot;: &quot;auto-pipeline&quot;,
                &quot;environment&quot;: [&quot;production&quot;],
                &quot;expires_at&quot;: None,
                &quot;pipeline_version&quot;: &quot;v1.2&quot;
            }

            final_content = f&quot;---\n{yaml.dump(frontmatter, allow_unicode=True, default_flow_style=False)}---\n\n{refined}&quot;

            # 6. 중복 체크
            duplicate_path = check_duplicate_in_wiki(refined, obj.object_name)
            if duplicate_path:
                # 기존 파일을 deprecated로 상태 변경
                print(f&quot;[WARN] Duplicate detected: {duplicate_path} → marking as deprecated&quot;)
                _deprecate_document(duplicate_path)

            # 7. wiki/active/[Category]/[SubCat]/ 경로로 저장
            target_path = f&quot;wiki/active/{meta.category}/{meta.sub_category}/{meta.optimized_filename}.md&quot;
            data = final_content.encode(&#39;utf-8&#39;)
            from io import BytesIO
            minio_client.put_object(BUCKET_NAME, target_path, BytesIO(data), len(data),
                                    content_type=&quot;text/markdown&quot;)

            print(f&quot;[OK] Classified: {obj.object_name} → {target_path} [{doc_id}]&quot;)

        except Exception as e:
            print(f&quot;[ERROR] Failed to process {obj.object_name}: {e}&quot;)
            import traceback; traceback.print_exc()

def _deprecate_document(minio_path: str):
    &quot;&quot;&quot;기존 문서의 status를 deprecated로 변경&quot;&quot;&quot;
    try:
        resp = minio_client.get_object(BUCKET_NAME, minio_path)
        content = resp.read().decode(&#39;utf-8&#39;)
        content = re.sub(r&#39;status: &quot;active&quot;&#39;, &#39;status: &quot;deprecated&quot;&#39;, content)
        data = content.encode(&#39;utf-8&#39;)
        from io import BytesIO
        minio_client.put_object(BUCKET_NAME, minio_path, BytesIO(data), len(data))
    except Exception as e:
        print(f&quot;[ERROR] Failed to deprecate {minio_path}: {e}&quot;)

if __name__ == &quot;__main__&quot;:
    process_raw_documents()</code></pre>
<hr>
<h3 id="3-3-git-동기화-스크립트-sync_to_gitsh">3-3. Git 동기화 스크립트 (<code>sync_to_git.sh</code>)</h3>
<pre><code class="language-bash">#!/bin/bash
# MinIO wiki/ 내용을 Git 저장소에 동기화
# Argo Workflows git-sync Pod에서 실행

set -euo pipefail

GIT_REPO_URL=&quot;https://${GIT_TOKEN}@${GIT_HOST}/platform/llm-wiki.git&quot;
CLONE_DIR=&quot;/workspace/llm-wiki&quot;
BUCKET=&quot;confluence-wiki&quot;

echo &quot;[STEP 1] Git clone or pull&quot;
if [ ! -d &quot;$CLONE_DIR/.git&quot; ]; then
    git clone &quot;$GIT_REPO_URL&quot; &quot;$CLONE_DIR&quot;
fi
cd &quot;$CLONE_DIR&quot;
git config user.email &quot;aiops-bot@internal.com&quot;
git config user.name &quot;AIOps Pipeline Bot&quot;
git pull origin main --rebase

echo &quot;[STEP 2] Sync MinIO wiki/ → local&quot;
mc alias set myminio &quot;${MINIO_URL}&quot; &quot;${MINIO_ACCESS_KEY}&quot; &quot;${MINIO_SECRET_KEY}&quot;
mc mirror myminio/confluence-wiki/wiki/ &quot;$CLONE_DIR/wiki/&quot; --overwrite

echo &quot;[STEP 3] Archive lifecycle check (365일 초과 deprecated 문서 자동 이동)&quot;
python3 /app/lifecycle_manager.py --action archive --clone-dir &quot;$CLONE_DIR&quot;

echo &quot;[STEP 4] Git commit &amp; push&quot;
git add wiki/
CHANGED=$(git diff --cached --name-only | wc -l)
if [ &quot;$CHANGED&quot; -gt &quot;0&quot; ]; then
    COMMIT_MSG=&quot;Auto-sync: ${CHANGED} docs updated [$(date +%Y-%m-%d %H:%M)] via AIOps Pipeline&quot;
    git commit -m &quot;$COMMIT_MSG&quot;
    git push origin main
    echo &quot;[OK] Pushed $CHANGED changed files to Git&quot;
else
    echo &quot;[SKIP] No changes detected, nothing to push&quot;
fi

echo &quot;[DONE] Git sync completed&quot;</code></pre>
<hr>
<h3 id="3-4-문서-생애주기-관리-lifecycle_managerpy">3-4. 문서 생애주기 관리 (<code>lifecycle_manager.py</code>)</h3>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
문서 생애주기 자동 관리
- 180일 미검증 → Slack 알림
- 365일 deprecated → archive 자동 이동
- Argo CronJob 또는 Git sync 후 호출
&quot;&quot;&quot;
import os
import re
import yaml
import datetime
import argparse
import requests
from pathlib import Path

SLACK_WEBHOOK = os.getenv(&quot;SLACK_WEBHOOK_URL&quot;)
ARCHIVE_DAYS  = 365
ALERT_DAYS    = 180

def parse_frontmatter(content: str) -&gt; dict:
    match = re.match(r&#39;^---\n(.*?)\n---&#39;, content, re.DOTALL)
    if match:
        try:
            return yaml.safe_load(match.group(1)) or {}
        except Exception:
            return {}
    return {}

def update_frontmatter(content: str, updates: dict) -&gt; str:
    match = re.match(r&#39;^(---\n)(.*?)(\n---)&#39;, content, re.DOTALL)
    if not match:
        return content
    try:
        fm = yaml.safe_load(match.group(2)) or {}
        fm.update(updates)
        new_fm = yaml.dump(fm, allow_unicode=True, default_flow_style=False).strip()
        return f&quot;---\n{new_fm}\n---{content[match.end():]}&quot;
    except Exception:
        return content

def send_slack_alert(doc_id: str, title: str, last_verified: str, file_path: str):
    if not SLACK_WEBHOOK:
        print(f&quot;[ALERT] {doc_id} ({title}): 검증 기간 초과 - {last_verified}&quot;)
        return
    msg = {
        &quot;blocks&quot;: [
            {&quot;type&quot;: &quot;section&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;mrkdwn&quot;,
             &quot;text&quot;: f&quot;:warning: *문서 검증 기간 초과 알림*\n&quot;
                     f&quot;*ID:* `{doc_id}`\n*제목:* {title}\n&quot;
                     f&quot;*마지막 검증:* {last_verified}\n*경로:* `{file_path}`&quot;}},
            {&quot;type&quot;: &quot;actions&quot;, &quot;elements&quot;: [
                {&quot;type&quot;: &quot;button&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;plain_text&quot;, &quot;text&quot;: &quot;검증 완료로 표시&quot;},
                 &quot;style&quot;: &quot;primary&quot;, &quot;value&quot;: doc_id},
                {&quot;type&quot;: &quot;button&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;plain_text&quot;, &quot;text&quot;: &quot;아카이브&quot;},
                 &quot;style&quot;: &quot;danger&quot;, &quot;value&quot;: f&quot;archive:{doc_id}&quot;}
            ]}
        ]
    }
    requests.post(SLACK_WEBHOOK, json=msg)

def run_lifecycle_check(clone_dir: str, action: str = &quot;check&quot;):
    today = datetime.date.today()
    wiki_path = Path(clone_dir) / &quot;wiki&quot; / &quot;active&quot;
    archived_count = 0
    alerted_count  = 0

    for md_file in wiki_path.rglob(&quot;*.md&quot;):
        content = md_file.read_text(encoding=&#39;utf-8&#39;)
        fm = parse_frontmatter(content)
        if not fm:
            continue

        status = fm.get(&quot;status&quot;, &quot;active&quot;)
        last_verified_str = str(fm.get(&quot;last_verified_at&quot;, &quot;&quot;))
        doc_id = fm.get(&quot;id&quot;, &quot;UNKNOWN&quot;)
        title  = fm.get(&quot;title&quot;, str(md_file.name))

        try:
            last_verified = datetime.date.fromisoformat(last_verified_str)
        except (ValueError, TypeError):
            last_verified = today - datetime.timedelta(days=ALERT_DAYS + 1)

        age_days = (today - last_verified).days

        # 365일 초과 deprecated → archive 폴더로 자동 이동
        if status == &quot;deprecated&quot; and age_days &gt;= ARCHIVE_DAYS and action == &quot;archive&quot;:
            archive_target = Path(clone_dir) / &quot;wiki&quot; / &quot;archive&quot; / md_file.relative_to(wiki_path)
            archive_target.parent.mkdir(parents=True, exist_ok=True)

            updated = update_frontmatter(content, {
                &quot;status&quot;: &quot;archive&quot;,
                &quot;archived_at&quot;: str(today),
                &quot;archive_reason&quot;: f&quot;Auto-archived after {age_days} days without verification&quot;
            })
            archive_target.write_text(updated, encoding=&#39;utf-8&#39;)
            md_file.unlink()
            archived_count += 1
            print(f&quot;[ARCHIVE] {doc_id}: {md_file.name} → archive/&quot;)

        # 180일 초과 active → Slack 경고
        elif status == &quot;active&quot; and age_days &gt;= ALERT_DAYS:
            if action in (&quot;check&quot;, &quot;alert&quot;):
                send_slack_alert(doc_id, title, last_verified_str, str(md_file))
                alerted_count += 1

    print(f&quot;[LIFECYCLE] archived={archived_count}, alerted={alerted_count}&quot;)

if __name__ == &quot;__main__&quot;:
    parser = argparse.ArgumentParser()
    parser.add_argument(&quot;--action&quot;, choices=[&quot;check&quot;, &quot;alert&quot;, &quot;archive&quot;], default=&quot;check&quot;)
    parser.add_argument(&quot;--clone-dir&quot;, default=&quot;/workspace/llm-wiki&quot;)
    args = parser.parse_args()
    run_lifecycle_check(args.clone_dir, args.action)</code></pre>
<hr>
<h3 id="3-5-vector-db-인덱싱-파이프라인-indexerpy">3-5. Vector DB 인덱싱 파이프라인 (<code>indexer.py</code>)</h3>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
Git wiki/ → Milvus Vector DB 증분 인덱싱
LangChain RecordManager로 중복/삭제 자동 동기화
Hybrid Search: Dense(Embedding) + Sparse(BM25) 지원
&quot;&quot;&quot;
import os
import re
from pathlib import Path
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings  # 폐쇄망용
from langchain_community.vectorstores import Milvus
from langchain.indexes import SQLRecordManager, index
from langchain_core.documents import Document
import yaml

WIKI_DIR    = os.getenv(&quot;WIKI_DIR&quot;, &quot;/workspace/llm-wiki/wiki/active&quot;)
MILVUS_HOST = os.getenv(&quot;MILVUS_HOST&quot;, &quot;milvus-service.storage&quot;)
MILVUS_PORT = os.getenv(&quot;MILVUS_PORT&quot;, &quot;19530&quot;)
MODEL_PATH  = os.getenv(&quot;EMBEDDING_MODEL_PATH&quot;, &quot;/models/bge-m3&quot;)  # 폐쇄망 로컬 모델
COLLECTION  = &quot;platform_ops_wiki&quot;
DB_URL      = os.getenv(&quot;RECORD_MANAGER_DB&quot;, &quot;sqlite:////data/record_manager.db&quot;)

# ── 임베딩 모델 (폐쇄망: HuggingFace 로컬 모델) ──────────────
embeddings = HuggingFaceEmbeddings(
    model_name=MODEL_PATH,
    model_kwargs={&#39;device&#39;: &#39;cuda&#39;},
    encode_kwargs={&#39;normalize_embeddings&#39;: True}
)

# ── Vector Store ──────────────────────────────────────────────
vector_store = Milvus(
    embedding_function=embeddings,
    collection_name=COLLECTION,
    connection_args={&quot;host&quot;: MILVUS_HOST, &quot;port&quot;: MILVUS_PORT},
    index_params={&quot;metric_type&quot;: &quot;COSINE&quot;},
)

# ── RecordManager: 중복/삭제 자동 동기화 ──────────────────────
record_manager = SQLRecordManager(namespace=f&quot;milvus/{COLLECTION}&quot;, db_url=DB_URL)
record_manager.create_schema()

# ── 마크다운 헤더 기반 청킹 ────────────────────────────────────
HEADERS_TO_SPLIT = [(&quot;#&quot;, &quot;h1&quot;), (&quot;##&quot;, &quot;h2&quot;), (&quot;###&quot;, &quot;h3&quot;)]
text_splitter = MarkdownHeaderTextSplitter(HEADERS_TO_SPLIT, strip_headers=False)

def parse_frontmatter(content: str) -&gt; dict:
    match = re.match(r&#39;^---\n(.*?)\n---&#39;, content, re.DOTALL)
    if match:
        try:
            return yaml.safe_load(match.group(1)) or {}
        except Exception:
            return {}
    return {}

def load_documents(wiki_dir: str) -&gt; list[Document]:
    &quot;&quot;&quot;wiki/active/ 모든 MD 파일 → LangChain Document 리스트 (메타데이터 포함)&quot;&quot;&quot;
    docs = []
    for md_path in Path(wiki_dir).rglob(&quot;*.md&quot;):
        content = md_path.read_text(encoding=&#39;utf-8&#39;)
        fm = parse_frontmatter(content)

        # status가 active인 문서만 인덱싱
        if fm.get(&quot;status&quot;) not in (&quot;active&quot;, None):
            continue

        # YAML Frontmatter 제거 후 본문만 청킹
        body = re.sub(r&#39;^---\n.*?\n---\n&#39;, &#39;&#39;, content, flags=re.DOTALL).strip()
        chunks = text_splitter.split_text(body)

        for chunk in chunks:
            # 청킹된 각 조각에 문서 메타데이터 부착
            chunk.metadata.update({
                &quot;doc_id&quot;:       fm.get(&quot;id&quot;, &quot;&quot;),
                &quot;category&quot;:     fm.get(&quot;category&quot;, &quot;&quot;),
                &quot;sub_category&quot;: fm.get(&quot;sub_category&quot;, &quot;&quot;),
                &quot;tech_stack&quot;:   &quot;,&quot;.join(fm.get(&quot;tech_stack&quot;, [])),
                &quot;severity&quot;:     fm.get(&quot;severity&quot;, &quot;&quot;),
                &quot;status&quot;:       fm.get(&quot;status&quot;, &quot;active&quot;),
                &quot;tags&quot;:         &quot;,&quot;.join(fm.get(&quot;tags&quot;, [])),
                &quot;summary&quot;:      fm.get(&quot;summary&quot;, &quot;&quot;),
                &quot;last_verified&quot;:str(fm.get(&quot;last_verified_at&quot;, &quot;&quot;)),
                &quot;file_path&quot;:    str(md_path.relative_to(wiki_dir)),
                &quot;source&quot;:       fm.get(&quot;source&quot;, &quot;&quot;),
            })
            docs.append(chunk)

    return docs

def run_indexing():
    print(f&quot;[INFO] Loading documents from {WIKI_DIR}&quot;)
    docs = load_documents(WIKI_DIR)
    print(f&quot;[INFO] Total chunks to index: {len(docs)}&quot;)

    # cleanup=&quot;incremental&quot;: 변경된 문서만 업데이트, 삭제된 문서는 DB에서도 삭제
    result = index(
        docs,
        record_manager,
        vector_store,
        cleanup=&quot;incremental&quot;,
        source_id_key=&quot;doc_id&quot;
    )
    print(f&quot;[DONE] Indexing result: {result}&quot;)

if __name__ == &quot;__main__&quot;:
    run_indexing()</code></pre>
<hr>
<h3 id="3-6-langgraph-aiops-모니터링-에이전트-aiops_agentpy">3-6. LangGraph AIOps 모니터링 에이전트 (<code>aiops_agent.py</code>)</h3>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
K8s 실시간 이벤트 + RAG(Wiki) 기반 AIOps 에이전트
기능:
  1. 이벤트 수집 &amp; 증상 분석
  2. 관련 SOP/Library RAG 검색
  3. 근본 원인 분석 (Root Cause Analysis)
  4. 해결책 생성 &amp; 실행 가능 명령어 제시
  5. 리스크 예측 (Proactive Risk Detection)
&quot;&quot;&quot;
import os
import json
from typing import TypedDict, Annotated, List, Optional
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import Milvus
from kubernetes import client, config

LLM_ENDPOINT  = os.getenv(&quot;LLM_ENDPOINT&quot;, &quot;http://vllm-service.ai:8000/v1&quot;)
MILVUS_HOST   = os.getenv(&quot;MILVUS_HOST&quot;, &quot;milvus-service.storage&quot;)
MODEL_PATH    = os.getenv(&quot;EMBEDDING_MODEL_PATH&quot;, &quot;/models/bge-m3&quot;)
COLLECTION    = &quot;platform_ops_wiki&quot;

llm = ChatOpenAI(model=&quot;llama-3-70b-instruct&quot;, base_url=LLM_ENDPOINT, api_key=&quot;internal&quot;, temperature=0)

embeddings = HuggingFaceEmbeddings(model_name=MODEL_PATH, model_kwargs={&#39;device&#39;: &#39;cpu&#39;})
vector_store = Milvus(
    embedding_function=embeddings,
    collection_name=COLLECTION,
    connection_args={&quot;host&quot;: MILVUS_HOST, &quot;port&quot;: MILVUS_PORT},
)

# ── K8s 클라이언트 초기화 ─────────────────────────────────────
try:
    config.load_incluster_config()
except Exception:
    config.load_kube_config()

k8s_core = client.CoreV1Api()
k8s_apps = client.AppsV1Api()

# ── 에이전트 상태 정의 ────────────────────────────────────────
class AIOpsState(TypedDict):
    messages: Annotated[list, add_messages]
    event_raw: str                    # 원본 K8s 이벤트/알람
    symptoms: str                     # 분석된 증상
    search_query: str                 # RAG 검색 쿼리
    retrieved_docs: List[dict]        # 검색된 관련 문서
    root_cause: str                   # 근본 원인 분석 결과
    solution: str                     # 제안된 해결책 + 명령어
    risk_prediction: str              # 예측된 리스크
    k8s_context: dict                 # K8s 클러스터 실시간 상태
    retry_count: int                  # 재시도 횟수 (루프 제어)
    confidence: float                 # 분석 신뢰도 (0~1)

# ── Node 1: K8s 컨텍스트 수집 ──────────────────────────────────
def collect_k8s_context(state: AIOpsState) -&gt; AIOpsState:
    &quot;&quot;&quot;이벤트와 관련된 K8s 실시간 상태 수집&quot;&quot;&quot;
    ctx = {}
    event_text = state[&quot;event_raw&quot;]

    try:
        # 네임스페이스 추출 시도
        ns_match = __import__(&#39;re&#39;).search(r&#39;namespace[=: ]+(\S+)&#39;, event_text, re.IGNORECASE)
        namespace = ns_match.group(1) if ns_match else &quot;kube-system&quot;

        # 최근 이벤트 수집
        events = k8s_core.list_namespaced_event(
            namespace=namespace,
            limit=20,
            field_selector=&quot;type=Warning&quot;
        )
        ctx[&quot;recent_warnings&quot;] = [
            {&quot;reason&quot;: e.reason, &quot;message&quot;: e.message, &quot;object&quot;: e.involved_object.name}
            for e in events.items
        ]

        # 비정상 파드 수집
        pods = k8s_core.list_namespaced_pod(namespace=namespace)
        ctx[&quot;unhealthy_pods&quot;] = [
            {&quot;name&quot;: p.metadata.name, &quot;phase&quot;: p.status.phase,
             &quot;conditions&quot;: [c.type for c in (p.status.conditions or []) if not c.status == &quot;True&quot;]}
            for p in pods.items
            if p.status.phase not in (&quot;Running&quot;, &quot;Succeeded&quot;)
        ]

        # 노드 상태
        nodes = k8s_core.list_node()
        ctx[&quot;node_summary&quot;] = {
            &quot;total&quot;: len(nodes.items),
            &quot;not_ready&quot;: [n.metadata.name for n in nodes.items
                          if not any(c.type == &quot;Ready&quot; and c.status == &quot;True&quot;
                                     for c in (n.status.conditions or []))]
        }

    except Exception as e:
        ctx[&quot;error&quot;] = str(e)

    state[&quot;k8s_context&quot;] = ctx
    return state

# ── Node 2: 증상 분석 ──────────────────────────────────────────
def analyze_symptoms(state: AIOpsState) -&gt; AIOpsState:
    &quot;&quot;&quot;이벤트 + K8s 컨텍스트 → 증상 구조화&quot;&quot;&quot;
    prompt = f&quot;&quot;&quot;
당신은 K8s 인프라 전문가입니다. 다음 이벤트와 클러스터 상태를 분석하여 정확한 증상을 파악하세요.

## 원본 이벤트/알람:
{state[&#39;event_raw&#39;]}

## 실시간 K8s 상태:
{json.dumps(state[&#39;k8s_context&#39;], ensure_ascii=False, indent=2)}

다음 형식으로 분석하세요:
1. **주요 증상**: 가장 명확한 이상 현상
2. **영향 범위**: 영향받는 컴포넌트/네임스페이스/노드
3. **심각도**: Critical/High/Medium/Low
4. **검색 키워드**: 관련 문서 검색에 쓸 핵심 키워드 (3~5개)
&quot;&quot;&quot;
    response = llm.invoke([HumanMessage(content=prompt)])
    state[&quot;symptoms&quot;] = response.content

    # 검색 쿼리 추출
    keywords = __import__(&#39;re&#39;).findall(r&#39;\*\*검색 키워드\*\*:?\s*(.+)&#39;, response.content)
    state[&quot;search_query&quot;] = keywords[0] if keywords else state[&quot;event_raw&quot;][:200]

    return state

# ── Node 3: RAG 검색 (Hybrid Search) ─────────────────────────
def retrieve_knowledge(state: AIOpsState) -&gt; AIOpsState:
    &quot;&quot;&quot;Milvus Hybrid Search: 의미 검색 + 메타데이터 필터링&quot;&quot;&quot;
    query = state[&quot;search_query&quot;]

    # 카테고리 필터: SOP 우선 검색
    sop_results = vector_store.similarity_search_with_score(
        query,
        k=3,
        expr=&#39;category == &quot;SOP&quot; and status == &quot;active&quot;&#39;
    )

    # Library/Architecture도 추가 검색
    lib_results = vector_store.similarity_search_with_score(
        query,
        k=3,
        expr=&#39;(category == &quot;Library&quot; or category == &quot;Architecture&quot;) and status == &quot;active&quot;&#39;
    )

    all_results = sop_results + lib_results
    # 유사도 점수 기준 정렬 (낮을수록 유사)
    all_results.sort(key=lambda x: x[1])

    retrieved = []
    for doc, score in all_results[:5]:
        retrieved.append({
            &quot;doc_id&quot;:   doc.metadata.get(&quot;doc_id&quot;),
            &quot;category&quot;: doc.metadata.get(&quot;category&quot;),
            &quot;severity&quot;: doc.metadata.get(&quot;severity&quot;),
            &quot;content&quot;:  doc.page_content,
            &quot;score&quot;:    round(score, 4),
            &quot;file_path&quot;:doc.metadata.get(&quot;file_path&quot;),
        })

    state[&quot;retrieved_docs&quot;] = retrieved
    return state

# ── Node 4: 근본 원인 분석 ────────────────────────────────────
def analyze_root_cause(state: AIOpsState) -&gt; AIOpsState:
    &quot;&quot;&quot;증상 + 검색 결과 → 근본 원인 분석 + 신뢰도 평가&quot;&quot;&quot;
    docs_context = &quot;\n\n---\n&quot;.join([
        f&quot;[{d[&#39;doc_id&#39;]}] (유사도: {d[&#39;score&#39;]})\n{d[&#39;content&#39;]}&quot;
        for d in state[&quot;retrieved_docs&quot;]
    ])

    prompt = f&quot;&quot;&quot;
당신은 1,000노드 K8s 클러스터 운영 전문가입니다.

## 분석된 증상:
{state[&#39;symptoms&#39;]}

## 참조 문서 (SOP/Library):
{docs_context}

## K8s 실시간 상태:
{json.dumps(state[&#39;k8s_context&#39;], ensure_ascii=False)}

근본 원인을 분석하세요:

1. **근본 원인**: 가장 가능성 높은 원인 (1~2가지)
2. **원인 근거**: 증상과 참조 문서를 연결한 논리적 근거
3. **관련 컴포넌트**: 직접 관련된 K8s/Cilium/MinIO 컴포넌트
4. **분석 신뢰도**: 0.0~1.0 (참조 문서의 관련성이 낮으면 낮게 평가)
&quot;&quot;&quot;
    response = llm.invoke([HumanMessage(content=prompt)])
    state[&quot;root_cause&quot;] = response.content

    # 신뢰도 추출
    conf_match = __import__(&#39;re&#39;).search(r&#39;분석 신뢰도.*?([0-9]\.[0-9]+)&#39;, response.content)
    state[&quot;confidence&quot;] = float(conf_match.group(1)) if conf_match else 0.5

    return state

# ── Node 5: 해결책 생성 ────────────────────────────────────────
def generate_solution(state: AIOpsState) -&gt; AIOpsState:
    &quot;&quot;&quot;근본 원인 → 단계별 해결책 + 실행 가능한 명령어 생성&quot;&quot;&quot;
    prompt = f&quot;&quot;&quot;
## 근본 원인 분석:
{state[&#39;root_cause&#39;]}

## 관련 SOP/Library 요약:
{chr(10).join([d[&#39;content&#39;][:500] for d in state[&#39;retrieved_docs&#39;][:2]])}

즉시 실행 가능한 해결책을 제시하세요:

1. **즉각 조치 (0~5분)**: 피해 최소화를 위한 긴급 조치
2. **단계별 복구 절차**: 순서대로 실행할 kubectl/cilium 명령어 포함
3. **검증 방법**: 복구 성공 여부를 확인하는 방법
4. **재발 방지**: 같은 이슈가 재발하지 않도록 하는 설정 변경

```bash
# 즉각 조치 명령어 예시
# (실제 리소스명으로 교체 필요)</code></pre>
<p><strong>주의사항</strong>: 이 명령어는 Production 환경에 적용 전 반드시 검토하세요.
&quot;&quot;&quot;
    response = llm.invoke([HumanMessage(content=prompt)])
    state[&quot;solution&quot;] = response.content
    return state</p>
<h1 id="──-node-6-리스크-예측-────────────────────────────────────────">── Node 6: 리스크 예측 ────────────────────────────────────────</h1>
<p>def predict_risks(state: AIOpsState) -&gt; AIOpsState:
    &quot;&quot;&quot;현재 상태 기반 근시일 내 발생 가능한 리스크 예측&quot;&quot;&quot;
    prompt = f&quot;&quot;&quot;</p>
<h2 id="현재-장애-상황">현재 장애 상황:</h2>
<p>{state[&#39;symptoms&#39;]}</p>
<h2 id="k8s-클러스터-상태">K8s 클러스터 상태:</h2>
<p>{json.dumps(state[&#39;k8s_context&#39;], ensure_ascii=False)}</p>
<h2 id="이미-수집된-운영-지식">이미 수집된 운영 지식:</h2>
<p>{chr(10).join([d[&#39;content&#39;][:300] for d in state[&#39;retrieved_docs&#39;][:3]])}</p>
<p>현재 상황을 방치하거나 부분 조치만 취했을 때 발생 가능한 리스크를 예측하세요:</p>
<ol>
<li><strong>단기 리스크 (24시간 내)</strong>: 연쇄 장애 가능성</li>
<li><strong>중기 리스크 (1주일 내)</strong>: 성능 저하 또는 데이터 손실 위험</li>
<li><strong>선제 조치 권장</strong>: 리스크를 사전에 차단하기 위한 예방 조치</li>
</ol>
<p>리스크별 확률(%)과 영향도(Critical/High/Medium)도 표시하세요.
&quot;&quot;&quot;
    response = llm.invoke([HumanMessage(content=prompt)])
    state[&quot;risk_prediction&quot;] = response.content
    return state</p>
<h1 id="──-조건부-엣지-신뢰도-낮으면-재검색-───────────────────────">── 조건부 엣지: 신뢰도 낮으면 재검색 ───────────────────────</h1>
<p>def should_retry_search(state: AIOpsState) -&gt; str:
    if state[&quot;confidence&quot;] &lt; 0.4 and state[&quot;retry_count&quot;] &lt; 2:
        state[&quot;retry_count&quot;] += 1
        state[&quot;search_query&quot;] = state[&quot;symptoms&quot;][:300]  # 더 넓은 쿼리로 재시도
        print(f&quot;[RETRY] Low confidence ({state[&#39;confidence&#39;]}), retrying search...&quot;)
        return &quot;retrieve_knowledge&quot;
    return &quot;generate_solution&quot;</p>
<h1 id="──-langgraph-워크플로우-구성-─────────────────────────────────">── LangGraph 워크플로우 구성 ─────────────────────────────────</h1>
<p>def build_aiops_graph() -&gt; StateGraph:
    graph = StateGraph(AIOpsState)</p>
<pre><code>graph.add_node(&quot;collect_k8s_context&quot;, collect_k8s_context)
graph.add_node(&quot;analyze_symptoms&quot;,    analyze_symptoms)
graph.add_node(&quot;retrieve_knowledge&quot;,  retrieve_knowledge)
graph.add_node(&quot;analyze_root_cause&quot;,  analyze_root_cause)
graph.add_node(&quot;generate_solution&quot;,   generate_solution)
graph.add_node(&quot;predict_risks&quot;,       predict_risks)

graph.set_entry_point(&quot;collect_k8s_context&quot;)
graph.add_edge(&quot;collect_k8s_context&quot;, &quot;analyze_symptoms&quot;)
graph.add_edge(&quot;analyze_symptoms&quot;,    &quot;retrieve_knowledge&quot;)
graph.add_edge(&quot;retrieve_knowledge&quot;,  &quot;analyze_root_cause&quot;)

# 조건부 엣지: 신뢰도 낮으면 재검색 루프
graph.add_conditional_edges(&quot;analyze_root_cause&quot;, should_retry_search,
                            {&quot;retrieve_knowledge&quot;: &quot;retrieve_knowledge&quot;,
                             &quot;generate_solution&quot;: &quot;generate_solution&quot;})
graph.add_edge(&quot;generate_solution&quot;, &quot;predict_risks&quot;)
graph.add_edge(&quot;predict_risks&quot;,     END)

return graph.compile()</code></pre><p>def analyze_event(event_text: str) -&gt; dict:
    &quot;&quot;&quot;외부에서 호출하는 메인 분석 함수&quot;&quot;&quot;
    graph = build_aiops_graph()
    initial_state = AIOpsState(
        messages=[],
        event_raw=event_text,
        symptoms=&quot;&quot;,
        search_query=&quot;&quot;,
        retrieved_docs=[],
        root_cause=&quot;&quot;,
        solution=&quot;&quot;,
        risk_prediction=&quot;&quot;,
        k8s_context={},
        retry_count=0,
        confidence=0.0,
    )
    result = graph.invoke(initial_state)
    return {
        &quot;symptoms&quot;:        result[&quot;symptoms&quot;],
        &quot;root_cause&quot;:      result[&quot;root_cause&quot;],
        &quot;solution&quot;:        result[&quot;solution&quot;],
        &quot;risk_prediction&quot;: result[&quot;risk_prediction&quot;],
        &quot;confidence&quot;:      result[&quot;confidence&quot;],
        &quot;referenced_docs&quot;: [d[&quot;doc_id&quot;] for d in result[&quot;retrieved_docs&quot;]],
    }</p>
<p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    # 테스트 이벤트
    test_event = &quot;&quot;&quot;
    ALERT: CiliumNetworkPolicy not enforced
    Namespace: production
    Node: k8s-worker-042
    Message: BGP peer 10.0.0.1 connection lost, routes not propagated
    Timestamp: 2026-04-29T03:14:00Z
    &quot;&quot;&quot;
    result = analyze_event(test_event)
    import json
    print(json.dumps(result, ensure_ascii=False, indent=2))</p>
<pre><code>
---

### 3-7. Argo Workflows 전체 파이프라인 YAML

```yaml
# argo-pipeline.yaml
# kubectl apply -f argo-pipeline.yaml
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
  name: llm-wiki-pipeline
  namespace: platform-ops
spec:
  schedule: &quot;0 2 * * *&quot;     # 매일 새벽 2시
  timezone: &quot;Asia/Seoul&quot;
  concurrencyPolicy: Forbid  # 중복 실행 방지
  workflowSpec:
    serviceAccountName: argo-wiki-sa
    entrypoint: main-pipeline

    # 공유 볼륨 (파이프라인 단계 간 데이터 공유)
    volumes:
    - name: workspace
      persistentVolumeClaim:
        claimName: wiki-pipeline-pvc

    templates:
    # ── 메인 DAG ───────────────────────────────────────────────
    - name: main-pipeline
      dag:
        tasks:
        - name: step1-extract
          template: confluence-extractor

        - name: step2-process
          dependencies: [step1-extract]
          template: langchain-processor

        - name: step3-lifecycle
          dependencies: [step2-process]
          template: lifecycle-manager

        - name: step4-git-sync
          dependencies: [step3-lifecycle]
          template: git-syncer

        - name: step5-indexing
          dependencies: [step4-git-sync]
          template: vector-indexer

        - name: step6-notify
          dependencies: [step5-indexing]
          template: slack-notifier

    # ── Step 1: Confluence 증분 추출 ───────────────────────────
    - name: confluence-extractor
      container:
        image: internal-reg.com/wiki-pipeline/extractor:v1.2
        command: [&quot;python&quot;, &quot;incremental_export.py&quot;]
        resources:
          requests: {cpu: &quot;500m&quot;, memory: &quot;1Gi&quot;}
          limits:   {cpu: &quot;2&quot;, memory: &quot;2Gi&quot;}
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    # ── Step 2: LLM 분류 처리 (GPU 활용) ──────────────────────
    - name: langchain-processor
      container:
        image: internal-reg.com/wiki-pipeline/processor:v1.2
        command: [&quot;python&quot;, &quot;processor.py&quot;]
        resources:
          requests: {cpu: &quot;2&quot;, memory: &quot;8Gi&quot;, &quot;nvidia.com/gpu&quot;: &quot;1&quot;}
          limits:   {cpu: &quot;8&quot;, memory: &quot;16Gi&quot;, &quot;nvidia.com/gpu&quot;: &quot;1&quot;}
        nodeSelector:
          accelerator: nvidia-gpu
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        env:
        - name: LLM_ENDPOINT
          value: &quot;http://vllm-service.ai-namespace:8000/v1&quot;
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    # ── Step 3: 생애주기 관리 ──────────────────────────────────
    - name: lifecycle-manager
      container:
        image: internal-reg.com/wiki-pipeline/processor:v1.2
        command: [&quot;python&quot;, &quot;lifecycle_manager.py&quot;, &quot;--action&quot;, &quot;archive&quot;,
                  &quot;--clone-dir&quot;, &quot;/workspace/llm-wiki&quot;]
        resources:
          requests: {cpu: &quot;200m&quot;, memory: &quot;512Mi&quot;}
          limits:   {cpu: &quot;1&quot;, memory: &quot;1Gi&quot;}
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    # ── Step 4: Git 동기화 ─────────────────────────────────────
    - name: git-syncer
      container:
        image: internal-reg.com/wiki-pipeline/git-tools:v1.0
        command: [&quot;/bin/bash&quot;, &quot;/app/sync_to_git.sh&quot;]
        resources:
          requests: {cpu: &quot;200m&quot;, memory: &quot;256Mi&quot;}
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace

    # ── Step 5: Vector DB 인덱싱 ──────────────────────────────
    - name: vector-indexer
      container:
        image: internal-reg.com/wiki-pipeline/indexer:v1.2
        command: [&quot;python&quot;, &quot;indexer.py&quot;]
        resources:
          requests: {cpu: &quot;1&quot;, memory: &quot;4Gi&quot;}
          limits:   {cpu: &quot;4&quot;, memory: &quot;8Gi&quot;}
        env:
        - name: WIKI_DIR
          value: &quot;/workspace/llm-wiki/wiki/active&quot;
        - name: EMBEDDING_MODEL_PATH
          value: &quot;/models/bge-m3&quot;
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
        volumeMounts:
        - name: workspace
          mountPath: /workspace
        - name: models
          mountPath: /models
          readOnly: true
      volumes:
      - name: models
        hostPath:
          path: /data/models

    # ── Step 6: 완료 알림 ─────────────────────────────────────
    - name: slack-notifier
      container:
        image: curlimages/curl:latest
        command: [sh, -c]
        args:
        - |
          curl -X POST $SLACK_WEBHOOK_URL \
            -H &#39;Content-type: application/json&#39; \
            -d &#39;{&quot;text&quot;:&quot;✅ LLM Wiki 파이프라인 완료 (&#39;&quot;$(date &#39;+%Y-%m-%d %H:%M&#39;)&quot;&#39;)\n자세한 내용: Argo UI 확인&quot;}&#39;
        envFrom:
        - secretRef:
            name: wiki-pipeline-secrets
---
# K8s Secret (실제 배포 시 Vault 또는 Sealed Secrets 권장)
apiVersion: v1
kind: Secret
metadata:
  name: wiki-pipeline-secrets
  namespace: platform-ops
type: Opaque
stringData:
  CONFLUENCE_URL:   &quot;https://your-domain.atlassian.net/wiki&quot;
  CONFLUENCE_USER:  &quot;admin@company.com&quot;
  CONFLUENCE_TOKEN: &quot;your-api-token&quot;
  PARENT_PAGE_ID:   &quot;12345678&quot;
  MINIO_URL:        &quot;minio.storage.svc.cluster.local:9000&quot;
  MINIO_ACCESS_KEY: &quot;minio-user&quot;
  MINIO_SECRET_KEY: &quot;minio-password&quot;
  GIT_TOKEN:        &quot;your-git-token&quot;
  GIT_HOST:         &quot;bitbucket.internal.com&quot;
  MILVUS_HOST:      &quot;milvus.storage.svc.cluster.local&quot;
  SLACK_WEBHOOK_URL:&quot;https://hooks.slack.com/services/...&quot;</code></pre><hr>
<h3 id="3-8-aiops-api-서버-api_serverpy">3-8. AIOps API 서버 (<code>api_server.py</code>)</h3>
<p>Lens 로컬 PC 및 모니터링 시스템에서 에이전트를 호출하는 FastAPI 엔드포인트입니다.</p>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
AIOps Agent FastAPI 서버
- Lens(로컬 PC) 에서 HTTP 호출
- Alertmanager Webhook 수신
- 모니터링 시스템 연동
&quot;&quot;&quot;
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
from typing import Optional
import asyncio
from aiops_agent import analyze_event  # 위의 LangGraph 에이전트 임포트
import uvicorn

app = FastAPI(title=&quot;AIOps Agent API&quot;, version=&quot;1.0.0&quot;)

class EventRequest(BaseModel):
    event_text: str
    source: str = &quot;manual&quot;        # manual | alertmanager | prometheus | lens
    namespace: Optional[str] = None
    severity: Optional[str] = None

class AlertmanagerWebhook(BaseModel):
    &quot;&quot;&quot;Prometheus Alertmanager Webhook 형식&quot;&quot;&quot;
    alerts: list
    groupLabels: dict
    commonAnnotations: dict

# ── 즉시 분석 엔드포인트 ─────────────────────────────────────
@app.post(&quot;/analyze&quot;)
async def analyze(req: EventRequest):
    &quot;&quot;&quot;이벤트 텍스트 즉시 분석 (Lens 또는 사내 모니터링 시스템에서 호출)&quot;&quot;&quot;
    result = await asyncio.get_event_loop().run_in_executor(
        None, analyze_event, req.event_text
    )
    return {
        &quot;status&quot;: &quot;ok&quot;,
        &quot;source&quot;: req.source,
        &quot;analysis&quot;: result
    }

# ── Alertmanager Webhook 수신 ─────────────────────────────────
@app.post(&quot;/webhook/alertmanager&quot;)
async def alertmanager_webhook(payload: AlertmanagerWebhook, background_tasks: BackgroundTasks):
    &quot;&quot;&quot;Prometheus Alertmanager에서 실시간 알람 수신 → 백그라운드 분석&quot;&quot;&quot;
    for alert in payload.alerts:
        if alert.get(&quot;status&quot;) == &quot;firing&quot;:
            event_text = (
                f&quot;ALERT: {alert.get(&#39;labels&#39;, {}).get(&#39;alertname&#39;, &#39;Unknown&#39;)}\n&quot;
                f&quot;Namespace: {alert.get(&#39;labels&#39;, {}).get(&#39;namespace&#39;, &#39;unknown&#39;)}\n&quot;
                f&quot;Severity: {alert.get(&#39;labels&#39;, {}).get(&#39;severity&#39;, &#39;unknown&#39;)}\n&quot;
                f&quot;Summary: {alert.get(&#39;annotations&#39;, {}).get(&#39;summary&#39;, &#39;&#39;)}\n&quot;
                f&quot;Description: {alert.get(&#39;annotations&#39;, {}).get(&#39;description&#39;, &#39;&#39;)}&quot;
            )
            background_tasks.add_task(_analyze_and_notify, event_text)
    return {&quot;status&quot;: &quot;received&quot;}

async def _analyze_and_notify(event_text: str):
    &quot;&quot;&quot;백그라운드: 분석 후 Slack으로 결과 전송&quot;&quot;&quot;
    import httpx, os
    result = await asyncio.get_event_loop().run_in_executor(None, analyze_event, event_text)

    slack_msg = {
        &quot;blocks&quot;: [
            {&quot;type&quot;: &quot;header&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;plain_text&quot;,
             &quot;text&quot;: f&quot;🚨 AIOps 장애 분석 결과 (신뢰도: {result[&#39;confidence&#39;]:.0%})&quot;}},
            {&quot;type&quot;: &quot;section&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;mrkdwn&quot;,
             &quot;text&quot;: f&quot;*📋 증상 요약*\n{result[&#39;symptoms&#39;][:500]}&quot;}},
            {&quot;type&quot;: &quot;divider&quot;},
            {&quot;type&quot;: &quot;section&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;mrkdwn&quot;,
             &quot;text&quot;: f&quot;*🔍 근본 원인*\n{result[&#39;root_cause&#39;][:600]}&quot;}},
            {&quot;type&quot;: &quot;divider&quot;},
            {&quot;type&quot;: &quot;section&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;mrkdwn&quot;,
             &quot;text&quot;: f&quot;*🛠 해결책*\n{result[&#39;solution&#39;][:600]}&quot;}},
            {&quot;type&quot;: &quot;divider&quot;},
            {&quot;type&quot;: &quot;section&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;mrkdwn&quot;,
             &quot;text&quot;: f&quot;*⚠️ 리스크 예측*\n{result[&#39;risk_prediction&#39;][:400]}&quot;}},
            {&quot;type&quot;: &quot;context&quot;, &quot;elements&quot;: [
                {&quot;type&quot;: &quot;mrkdwn&quot;,
                 &quot;text&quot;: f&quot;참조 문서: {&#39;, &#39;.join(result[&#39;referenced_docs&#39;])}&quot;}
            ]}
        ]
    }

    webhook_url = os.getenv(&quot;SLACK_WEBHOOK_URL&quot;)
    if webhook_url:
        async with httpx.AsyncClient() as client:
            await client.post(webhook_url, json=slack_msg)

# ── 리스크 예측 전용 엔드포인트 ──────────────────────────────
@app.get(&quot;/risk-scan&quot;)
async def proactive_risk_scan():
    &quot;&quot;&quot;정기 리스크 스캔 (cron으로 매시간 호출 가능)&quot;&quot;&quot;
    from kubernetes import client as k8s, config
    config.load_incluster_config()
    v1 = k8s.CoreV1Api()

    # 경고 이벤트 수집
    events = v1.list_event_for_all_namespaces(
        field_selector=&quot;type=Warning&quot;, limit=50
    )
    event_summary = &quot;\n&quot;.join([
        f&quot;{e.involved_object.namespace}/{e.involved_object.name}: {e.reason} - {e.message}&quot;
        for e in events.items
    ])

    if not event_summary:
        return {&quot;status&quot;: &quot;healthy&quot;, &quot;message&quot;: &quot;No warning events detected&quot;}

    result = await asyncio.get_event_loop().run_in_executor(
        None, analyze_event, f&quot;정기 리스크 스캔\n수집된 경고 이벤트:\n{event_summary}&quot;
    )
    return {&quot;status&quot;: &quot;risks_detected&quot;, &quot;analysis&quot;: result}

# ── Wiki 검색 엔드포인트 ──────────────────────────────────────
@app.get(&quot;/search&quot;)
async def search_wiki(q: str, category: Optional[str] = None, limit: int = 5):
    &quot;&quot;&quot;Lens에서 직접 Wiki 검색 (RAG 없이 순수 검색)&quot;&quot;&quot;
    from langchain_community.vectorstores import Milvus as MilvusVS
    from langchain_huggingface import HuggingFaceEmbeddings
    import os

    emb = HuggingFaceEmbeddings(model_name=os.getenv(&quot;EMBEDDING_MODEL_PATH&quot;, &quot;/models/bge-m3&quot;))
    vs = MilvusVS(embedding_function=emb, collection_name=&quot;platform_ops_wiki&quot;,
                  connection_args={&quot;host&quot;: os.getenv(&quot;MILVUS_HOST&quot;), &quot;port&quot;: &quot;19530&quot;})

    expr = f&#39;category == &quot;{category}&quot; and status == &quot;active&quot;&#39; if category else &#39;status == &quot;active&quot;&#39;
    results = vs.similarity_search_with_score(q, k=limit, expr=expr)

    return {&quot;results&quot;: [
        {&quot;doc_id&quot;: r.metadata.get(&quot;doc_id&quot;), &quot;score&quot;: s,
         &quot;summary&quot;: r.metadata.get(&quot;summary&quot;), &quot;file&quot;: r.metadata.get(&quot;file_path&quot;)}
        for r, s in results
    ]}

if __name__ == &quot;__main__&quot;:
    uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=8080)</code></pre>
<hr>
<h2 id="4-lens-연동-설정-로컬-pc">4. Lens 연동 설정 (로컬 PC)</h2>
<p>Lens에서 사내 AIOps API 서버와 연동하는 방법입니다.</p>
<h3 id="4-1-lens-extension-설정-prometheus-metrics-연동">4-1. Lens Extension 설정 (Prometheus Metrics 연동)</h3>
<pre><code class="language-yaml"># lens-extension-config.yaml
# Lens &gt; Extensions &gt; AIOps 설정
aiops:
  apiEndpoint: &quot;https://aiops-agent.internal.com&quot;
  alertWebhook: &quot;/webhook/alertmanager&quot;
  searchEndpoint: &quot;/search&quot;

  # 사내 LLM API (Lens에서 직접 쿼리)
  llmApi:
    endpoint: &quot;https://llm-internal.company.com/v1&quot;
    model: &quot;llama-3-70b&quot;
    authHeader: &quot;Bearer ${INTERNAL_LLM_TOKEN}&quot;</code></pre>
<h3 id="4-2-alertmanager-연동-설정">4-2. Alertmanager 연동 설정</h3>
<pre><code class="language-yaml"># alertmanager-config.yaml (기존 Alertmanager에 webhook 추가)
route:
  group_by: [&#39;alertname&#39;, &#39;namespace&#39;]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: &#39;slack-default&#39;
  routes:
  - match:
      severity: critical
    receiver: &#39;aiops-agent&#39;    # Critical 알람은 AIOps 에이전트로

receivers:
- name: &#39;aiops-agent&#39;
  webhook_configs:
  - url: &#39;http://aiops-agent-svc.platform-ops:8080/webhook/alertmanager&#39;
    send_resolved: false

- name: &#39;slack-default&#39;
  slack_configs:
  - api_url: &#39;https://hooks.slack.com/services/...&#39;
    channel: &#39;#platform-alerts&#39;</code></pre>
<hr>
<h2 id="5-git-폴더-구조-최종">5. Git 폴더 구조 (최종)</h2>
<pre><code>llm-wiki/                          ← Git 저장소 루트
├── raw/                           ← Confluence 원본 (수정 금지)
│   └── 2026-04-29/
│       ├── Cilium-BGP-Guide.md
│       └── MinIO-Scaling.md
│
├── wiki/
│   ├── active/                    ← 현재 운영 중인 지식 (RAG 대상)
│   │   ├── SOP/
│   │   │   ├── Network/
│   │   │   │   └── cilium-bgp-peering-recovery.md
│   │   │   └── Storage/
│   │   │       └── minio-node-expansion.md
│   │   ├── Library/
│   │   │   ├── Cilium/
│   │   │   └── K8s/
│   │   ├── Architecture/
│   │   └── Reports/
│   │
│   └── archive/                   ← 폐기된 구버전 문서 (RAG 제외)
│       └── SOP/Network/
│           └── cilium-1.14-bgp-old.md
│
├── scripts/                       ← 파이프라인 스크립트
│   ├── incremental_export.py
│   ├── processor.py
│   ├── lifecycle_manager.py
│   ├── indexer.py
│   └── sync_to_git.sh
│
└── .gitlab-ci.yml                 ← GitLab 전환 후 사용할 CI 설정</code></pre><hr>
<h2 id="6-gitlab-cicd-6개월-후-전환-대비">6. GitLab CI/CD (6개월 후 전환 대비)</h2>
<pre><code class="language-yaml"># .gitlab-ci.yml
stages:
  - validate
  - index
  - notify

validate-lifecycle:
  stage: validate
  image: internal-reg.com/wiki-pipeline/processor:v1.2
  script:
    - python scripts/lifecycle_manager.py --action check --clone-dir .
  rules:
    - if: $CI_COMMIT_BRANCH == &quot;main&quot;
      changes:
        - &quot;wiki/**/*&quot;

vector-indexing:
  stage: index
  image: internal-reg.com/wiki-pipeline/indexer:v1.2
  script:
    - python scripts/indexer.py
  variables:
    WIKI_DIR: &quot;./wiki/active&quot;
  rules:
    - if: $CI_COMMIT_BRANCH == &quot;main&quot;
      changes:
        - &quot;wiki/active/**/*&quot;

notify-update:
  stage: notify
  image: curlimages/curl
  script:
    - |
      CHANGED=$(git diff HEAD~1 --name-only wiki/ | wc -l)
      curl -X POST $SLACK_WEBHOOK_URL \
        -d &quot;{\&quot;text\&quot;:\&quot;📚 Wiki 업데이트: ${CHANGED}개 문서 변경 (${CI_COMMIT_SHORT_SHA})\&quot;}&quot;
  rules:
    - if: $CI_COMMIT_BRANCH == &quot;main&quot;</code></pre>
<hr>
<h2 id="7-전체-운영-체크리스트">7. 전체 운영 체크리스트</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td><strong>인프라</strong></td>
<td>MinIO AIStor 버킷 생성 (<code>confluence-wiki</code>)</td>
<td>□</td>
</tr>
<tr>
<td><strong>인프라</strong></td>
<td>Milvus K8s Operator 설치</td>
<td>□</td>
</tr>
<tr>
<td><strong>인프라</strong></td>
<td>vLLM Pod 배포 (GPU 노드)</td>
<td>□</td>
</tr>
<tr>
<td><strong>인프라</strong></td>
<td>BGE-M3 임베딩 모델 반입 (폐쇄망)</td>
<td>□</td>
</tr>
<tr>
<td><strong>파이프라인</strong></td>
<td>Argo Workflows CronWorkflow 배포</td>
<td>□</td>
</tr>
<tr>
<td><strong>파이프라인</strong></td>
<td>K8s Secret 생성 (Confluence/Git/MinIO 인증정보)</td>
<td>□</td>
</tr>
<tr>
<td><strong>파이프라인</strong></td>
<td>첫 전체 추출 실행 (manual trigger)</td>
<td>□</td>
</tr>
<tr>
<td><strong>인덱싱</strong></td>
<td>Vector DB 초기 인덱싱 완료 확인</td>
<td>□</td>
</tr>
<tr>
<td><strong>모니터링</strong></td>
<td>AIOps API 서버 배포</td>
<td>□</td>
</tr>
<tr>
<td><strong>모니터링</strong></td>
<td>Alertmanager Webhook 연동</td>
<td>□</td>
</tr>
<tr>
<td><strong>로컬</strong></td>
<td>Lens AIOps Extension 설정</td>
<td>□</td>
</tr>
<tr>
<td><strong>거버넌스</strong></td>
<td>Slack 알림 채널 설정 (180일 검증 알림)</td>
<td>□</td>
</tr>
<tr>
<td><strong>전환</strong></td>
<td>GitLab <code>.gitlab-ci.yml</code> 준비 (6개월 후)</td>
<td>□</td>
</tr>
</tbody></table>
<hr>
<p><em>문서 ID: ARCH-OPS-001 | 작성일: 2026-04-29 | 버전: v1.0 | 검토자: Platform Lead</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A29a]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A29a</link>
            <guid>https://velog.io/@youngkyoo_kim/26A29a</guid>
            <pubDate>Tue, 28 Apr 2026 22:02:57 GMT</pubDate>
            <description><![CDATA[<p>Obsidian을 회사 서버에서 활용하는 것은 기술적으로 가능하며, 특정 워크플로우에서는 강력한 경쟁력을 가집니다. 다만, Obsidian은 본래 <strong>&#39;로컬 퍼스트(Local-first)&#39;</strong> 앱이기 때문에 Notion 같은 클라우드 기반 툴과는 서버 활용 방식이 조금 다릅니다.</p>
<hr>
<h2 id="1-회사-서버에서-obsidian을-사용하는-방법">1. 회사 서버에서 Obsidian을 사용하는 방법</h2>
<p>Obsidian 자체는 설치형 데스크톱 앱이지만, 서버를 활용해 데이터를 관리하거나 협업 환경을 구축하는 방법은 크게 세 가지가 있습니다.</p>
<h3 id="①-데이터-동기화-서버-구축-self-hosted-sync"><strong>① 데이터 동기화 서버 구축 (Self-hosted Sync)</strong></h3>
<p>가장 일반적인 방법입니다. 회사 내부에 데이터 주권을 유지하면서 여러 기기에서 노트를 동기화하고 싶을 때 사용합니다.</p>
<ul>
<li><strong>Obsidian LiveSync:</strong> 오픈소스 플러그인을 사용하여 서버에 <strong>CouchDB</strong>를 설치하고 실시간 동기화 서버로 활용할 수 있습니다.</li>
<li><strong>Syncthing / Git:</strong> 서버를 중앙 저장소로 두고 Syncthing으로 파일을 동기화하거나, Git 레포지토리를 만들어 회사 서버에 Push/Pull 하는 방식입니다. 엔지니어링 조직에서 선호하는 방식입니다.</li>
</ul>
<h3 id="②-브라우저-접속형-obsidian-remote"><strong>② 브라우저 접속형 (Obsidian-Remote)</strong></h3>
<p>서버에 <strong>Docker</strong> 컨테이너로 Obsidian을 띄우고 웹 브라우저를 통해 접속하는 방식입니다.</p>
<ul>
<li><code>linuxserver/obsidian</code> 같은 Docker 이미지를 사용하면 서버 자원을 이용해 Obsidian UI를 그대로 웹에서 쓸 수 있습니다. 보안상 외부 인터넷 연결이 제한된 환경에서 내부 서버에 설치해 쓰기에 적합합니다.</li>
</ul>
<h3 id="③-정적-사이트-생성-publish-대체"><strong>③ 정적 사이트 생성 (Publish 대체)</strong></h3>
<p>서버에 Obsidian 노트(Markdown)를 두고, 이를 Quartz나 Hugo 같은 정적 사이트 생성기(SSG)와 연결하여 <strong>사내 위키(Internal Wiki)</strong> 형태로 배포할 수 있습니다.</p>
<hr>
<h2 id="2-다른-경쟁-툴-대비-경쟁력-분석">2. 다른 경쟁 툴 대비 경쟁력 분석</h2>
<p>Obsidian이 Notion이나 Logseq 등과 비교해 갖는 확실한 우위 요소는 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th align="left">비교 항목</th>
<th align="left"><strong>Obsidian</strong></th>
<th align="left"><strong>Notion</strong></th>
<th align="left"><strong>Logseq</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>데이터 소유권</strong></td>
<td align="left"><strong>완전 로컬 (Markdown)</strong></td>
<td align="left">클라우드 종속 (Proprietary)</td>
<td align="left">로컬 (Markdown/Org)</td>
</tr>
<tr>
<td align="left"><strong>작동 속도</strong></td>
<td align="left">매우 빠름 (오프라인 우선)</td>
<td align="left">네트워크 상태에 따라 느려짐</td>
<td align="left">보통 (데이터 많을 시 저하)</td>
</tr>
<tr>
<td align="left"><strong>확장성</strong></td>
<td align="left">1,000개 이상의 커뮤니티 플러그인</td>
<td align="left">제한적 (API 중심)</td>
<td align="left">플러그인 생태계 성장 중</td>
</tr>
<tr>
<td align="left"><strong>보안/보안</strong></td>
<td align="left">폐쇄망 사용 가능 (보안 우수)</td>
<td align="left">서버 보안 정책에 의존</td>
<td align="left">폐쇄망 사용 가능</td>
</tr>
<tr>
<td align="left"><strong>구조</strong></td>
<td align="left">네트워크형 (Graph View)</td>
<td align="left">계층형 (Database)</td>
<td align="left">아웃라이너 (Block-based)</td>
</tr>
</tbody></table>
<h3 id="핵심-경쟁력-데이터-주권과-유연성"><strong>핵심 경쟁력: &quot;데이터 주권과 유연성&quot;</strong></h3>
<ul>
<li><strong>보안:</strong> 데이터가 클라우드가 아닌 우리 회사의 서버나 로컬 드라이브에만 존재하므로, 기밀 유지가 중요한 기업 환경에서 독보적입니다.</li>
<li><strong>범용성:</strong> 파일이 <code>.md</code>(Markdown) 형식이므로, 나중에 Obsidian을 안 쓰게 되더라도 데이터 변환 없이 다른 툴이나 스크립트에서 바로 읽을 수 있습니다.</li>
<li><strong>그래프 뷰(Graph View):</strong> 단순한 폴더 구조를 넘어 지식 간의 연결 고리를 시각화할 수 있어, 복잡한 프로젝트의 히스토리를 추적하거나 아이디어를 확장할 때 유리합니다.</li>
</ul>
<hr>
<h2 id="3-주의사항-상업적-라이선스">3. 주의사항: 상업적 라이선스</h2>
<p>기업(영리 목적의 조직)에서 2인 이상의 직원이 업무용으로 Obsidian을 사용할 경우, <strong>Commercial License($50/user/year)</strong>를 구입해야 합니다. </p>
<ul>
<li><strong>무료 버전:</strong> 개인적인 용도로만 무료입니다.</li>
<li><strong>상업 라이선스:</strong> 회사 업무용으로 사용할 때 필수이며, 이는 앱의 기능적 차이보다는 법적 준수와 개발팀 지원의 성격이 강합니다.</li>
</ul>
<h3 id="결론"><strong>결론</strong></h3>
<p>Obsidian은 <strong>&quot;우리 회사만의 폐쇄적이고 강력한 지식 베이스를 직접 통제하며 구축하고 싶다&quot;</strong>는 니즈가 있을 때 최고의 경쟁력을 발휘합니다. 특히 Git이나 Docker 환경에 익숙한 기술 조직이라면 사내 서버와 연동하여 커스텀 위키나 개인화된 업무 도구로 활용하기에 매우 적합합니다.</p>
<p>회사에서 구체적으로 어떤 워크플로우(예: 개인 메모 관리, 팀 공유 위키, 프로젝트 문서화 등)에 도입을 고려 중이신가요? 상황에 맞춰 더 구체적인 구성을 제안해 드릴 수 있습니다.</p>
<p>===</p>
<p>개인용 Obsidian을 회사 PC에 설치하고 Git으로 동기화하여 지식 베이스(LLM Wiki)를 구축하고, 이를 AIOps로 확장하려는 계획은 매우 전략적이고 실용적인 접근입니다. 특히 K8s, Cilium, MinIO AIStor와 같은 대규모 인프라를 다루는 환경에서 마크다운(MD) 기반의 문서화는 데이터 활용도를 극대화할 수 있습니다.</p>
<p>제안하신 내용을 바탕으로 <strong>&#39;지식 생성 - 자동 관리 - AIOps 활용&#39;</strong>으로 이어지는 전반적인 아키텍처를 설계해 보았습니다.</p>
<hr>
<h2 id="1-전반적인-시스템-구조-high-level-architecture">1. 전반적인 시스템 구조 (High-Level Architecture)</h2>
<p>이 구조의 핵심은 <strong>&quot;Obsidian을 단순한 메모 도구가 아닌, AI가 읽을 수 있는 정형화된 데이터 소스(Single Source of Truth)&quot;</strong>로 만드는 것입니다.</p>
<h3 id="layer-1-지식-생성-및-편집-obsidian--git"><strong>Layer 1: 지식 생성 및 편집 (Obsidian &amp; Git)</strong></h3>
<ul>
<li><strong>환경:</strong> 회사 PC 로컬에 Obsidian 설치.</li>
<li><strong>동기화:</strong> <code>Obsidian Git</code> 플러그인을 사용하여 사내 또는 개인 Git 레포지토리와 연동 (Auto Backup/Pull 설정).</li>
<li><strong>콘텐츠 분류:</strong><ul>
<li><code>/Library</code>: K8s, Cilium, MinIO 등 벤더 공식 문서의 핵심 요약 및 테크니컬 노하우.</li>
<li><code>/SOP</code>: 장애 대응 절차서, 클러스터 스케일 아웃 가이드 등.</li>
<li><code>/Operations</code>: 작업계획서, 점검 일지 (날짜별 관리).</li>
<li><code>/Templates</code>: 문서 표준화를 위한 MD 템플릿 (작업 계획서 양식 등).</li>
</ul>
</li>
</ul>
<h3 id="layer-2-지식-파이프라인-및-관리-automation"><strong>Layer 2: 지식 파이프라인 및 관리 (Automation)</strong></h3>
<ul>
<li><strong>업데이트 자동화:</strong> 벤더 Docs의 업데이트를 추적하기 위해 특정 스크립트나 n8n과 같은 자동화 툴을 활용하여 변경 사항을 MD로 변환 후 Git에 자동 Push 하도록 구성할 수 있습니다.</li>
<li><strong>CI/CD 연동:</strong> Git에 Push가 발생하면 이를 트리거로 하여 지식 베이스를 검증하거나 후속 레이어로 전달합니다.</li>
</ul>
<h3 id="layer-3-aiops-및-지식-활용-agentic-ai--rag"><strong>Layer 3: AIOps 및 지식 활용 (Agentic AI &amp; RAG)</strong></h3>
<ul>
<li><strong>RAG (Retrieval-Augmented Generation):</strong> Git에 저장된 MD 파일들을 벡터 데이터베이스(Vector DB)로 인덱싱합니다.</li>
<li><strong>LLM 연동:</strong> 인프라 모니터링 시스템(Prometheus/Grafana)에서 알람이 발생하면, LLM 에이전트가 저장된 SOP와 벤더 Docs를 검색하여 원인을 분석하고 조치 방안을 제시합니다.</li>
<li><strong>AIOps 활용 사례:</strong><ul>
<li>&quot;Cilium BGP 피어링 에러 발생 시 대응 SOP 요약해줘.&quot;</li>
<li>&quot;지난번 MinIO AIStor 확장 시 발생했던 이슈와 해결책 찾아줘.&quot;</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-기술적-구현-포인트-및-경쟁력">2. 기술적 구현 포인트 및 경쟁력</h2>
<h3 id="①-md-기반의-paved-road-구축"><strong>① MD 기반의 &#39;Paved Road&#39; 구축</strong></h3>
<ul>
<li>단순 메모가 아니라, <strong>LLM이 이해하기 쉬운 구조화된 마크다운</strong>을 작성하는 것이 핵심입니다. Metadata(YAML)를 적극 활용하여 문서의 속성(장비, 서비스, 중요도 등)을 정의하면 향후 AI 에이전트가 정보를 추출하기 훨씬 수월해집니다.</li>
</ul>
<h3 id="②-인프라-가시성과의-결합"><strong>② 인프라 가시성과의 결합</strong></h3>
<ul>
<li>Cilium의 eBPF 기반 네트워킹이나 MinIO의 고성능 스토리지 운영은 매우 복잡합니다. Obsidian의 <strong>Graph View</strong>를 통해 서비스 간의 의존성이나 인프라 구성 요소를 시각적으로 연결해 두면, 단순 텍스트 검색보다 훨씬 직관적인 장애 추적이 가능합니다.</li>
</ul>
<h3 id="③-git을-통한-버전-관리-및-히스토리"><strong>③ Git을 통한 버전 관리 및 히스토리</strong></h3>
<ul>
<li>작업계획서나 SOP를 Git으로 관리하면 &quot;누가, 언제, 왜&quot; 설정을 변경했는지 완벽한 추적이 가능합니다. 이는 AIOps 단계에서 과거의 특정 시점 설정과 현재의 장애 상황을 비교 분석하는 데 결정적인 데이터를 제공합니다.</li>
</ul>
<hr>
<h2 id="3-aiops로-가기-위한-단계별-제언">3. AIOps로 가기 위한 단계별 제언</h2>
<ol>
<li><strong>1단계 (표준화):</strong> Obsidian 내에 작업계획서와 SOP를 위한 <strong>강력한 템플릿</strong>을 먼저 만드세요. AI는 일관된 구조의 문서에서 더 높은 정확도를 보여줍니다.</li>
<li><strong>2단계 (자동화):</strong> <code>n8n</code>이나 <code>GitHub Actions</code>를 활용해 Git 레포지토리의 MD 내용을 자동으로 벡터 DB에 반영하는 파이프라인을 구축해 보세요.</li>
<li><strong>3단계 (지능화):</strong> 구축된 지식 베이스를 기반으로, 인프라 이슈 발생 시 <strong>Prism AI나 gStack</strong> 같은 에이전트 툴이 이 문서를 먼저 읽고 답변하도록 프롬프트를 최적화합니다.</li>
</ol>
<p>이 구조는 데이터 소유권을 직접 가지면서도 최신 AI 트렌드인 RAG와 Agentic AI를 가장 효율적으로 결합할 수 있는 방식입니다. 특히 1,000노드 이상의 대규모 클러스터를 운영하시는 환경에서는 이러한 구조화된 지식 관리가 운영 효율성을 비약적으로 높여줄 것입니다.</p>
<p>구체적으로 벤더 Docs를 MD로 변환하는 자동화 로직이나, 특정 AIOps 도구와의 연동 방식에 대해 더 궁금한 점이 있으신가요?</p>
<p>===</p>
<p>아주 날카로운 질문입니다. 데이터가 이미 Git에 텍스트 파일(<code>.md</code>)로 존재하는데, 굳이 비용과 노력을 들여 벡터 데이터베이스(Vector DB)에 중복 저장하는 이유는 <strong>&quot;AI가 정보를 찾는 방식&quot;</strong>이 우리가 검색창에 단어를 치는 방식과 완전히 다르기 때문입니다.</p>
<p>결론부터 말씀드리면, <strong>Git은 &#39;보관과 히스토리&#39;를 위한 저장소</strong>이고, <strong>벡터 DB는 &#39;AI를 위한 인덱스(색인)&#39;</strong> 역할을 합니다.</p>
<hr>
<h2 id="1-키워드-검색-vs-의미론적semantic-검색">1. 키워드 검색 vs 의미론적(Semantic) 검색</h2>
<p>가장 큰 차이는 검색의 질입니다.</p>
<ul>
<li><strong>Git (키워드 매칭):</strong> <code>grep</code>이나 Git의 검색 기능은 정확히 일치하는 단어를 찾습니다.<ul>
<li>예: &quot;MinIO 연결 오류&quot;라고 검색하면, 문서에 정확히 이 단어가 있어야 나옵니다. &quot;MinIO connection timeout&quot;이나 &quot;스토리지 노드 통신 불가&quot;라는 문서가 있어도 단어가 다르면 못 찾을 확률이 높습니다.</li>
</ul>
</li>
<li><strong>벡터 DB (의미 매칭):</strong> 문장을 숫자의 집합(벡터)으로 변환하여 <strong>&#39;의미적 거리&#39;</strong>를 계산합니다.<ul>
<li>예: &quot;네트워크가 끊겼어&quot;라고 물어도, 벡터 DB는 의미가 유사한 &quot;Cilium BGP Peering Down&quot;이나 &quot;Packet Loss Troubleshooting&quot; 문서를 찾아낼 수 있습니다.</li>
</ul>
</li>
</ul>
<h2 id="2-llm의-컨텍스트-윈도우-한계-극복-rag의-핵심">2. LLM의 &#39;컨텍스트 윈도우&#39; 한계 극복 (RAG의 핵심)</h2>
<p>LLM(ChatGPT, Claude 등)은 한 번에 읽을 수 있는 데이터 양(Context Window)이 제한되어 있습니다. 1,000개가 넘는 MD 파일을 Git에서 한꺼번에 읽어와서 답변하라고 할 수는 없습니다.</p>
<ol>
<li><strong>사용자 질문:</strong> &quot;K8s 노드 추가 후 Cilium 파드가 안 떠.&quot;</li>
<li><strong>벡터 DB의 역할:</strong> 수천 개의 문서 중 <strong>이 질문과 의미적으로 가장 가까운 내용 3~5개만</strong> 순식간에 추려냅니다.</li>
<li><strong>LLM 전달:</strong> &quot;여기 Git에서 찾아온 관련 문서 조각 3개가 있어. 이걸 읽고 해결책을 알려줘.&quot;</li>
</ol>
<p>이 과정이 바로 <strong>RAG(Retrieval-Augmented Generation)</strong>이며, 벡터 DB가 없으면 AI는 수많은 파일 중 무엇을 읽어야 할지 알 수 없습니다.</p>
<hr>
<h2 id="3-aiops-실무에서의-결정적-차이">3. AIOps 실무에서의 결정적 차이</h2>
<p>운영 단계에서 벡터 DB가 필요한 실질적인 이유는 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th align="left">기능</th>
<th align="left">Git (전통적 방식)</th>
<th align="left">벡터 DB (AIOps 방식)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>장애 대응</strong></td>
<td align="left">장애 로그를 보고 사람이 관련 SOP 파일명을 직접 찾아야 함.</td>
<td align="left">에러 로그가 발생하면, AI 에러 로그의 의미를 파악해 <strong>즉시 관련 SOP를 띄워줌.</strong></td>
</tr>
<tr>
<td align="left"><strong>모호한 질문</strong></td>
<td align="left">&quot;지난주에 했던 스토리지 작업&quot; -&gt; 검색 불가.</td>
<td align="left">&quot;지난주 진행된 MinIO 관련 작업계획서&quot; -&gt; 날짜와 맥락으로 검색 가능.</td>
</tr>
<tr>
<td align="left"><strong>데이터 구조</strong></td>
<td align="left">비정형 텍스트 데이터.</td>
<td align="left">인공지능이 계산하기 좋은 수치(Embedding) 데이터.</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-구조적-제언-git은-source-vector-db는-cache">4. 구조적 제언: &quot;Git은 Source, Vector DB는 Cache&quot;</h2>
<p>질문하신 구조를 효율적으로 운영하시려면 다음과 같은 파이프라인을 추천합니다.</p>
<ol>
<li><strong>Source of Truth:</strong> 모든 문서는 <strong>Git</strong>에서 관리합니다. (사람이 수정하고 버전 관리)</li>
<li><strong>ETL(Extract, Transform, Load) 프로세스:</strong> Git에 커밋이 발생하면, CI/CD(예: GitHub Actions)가 동작하여 MD 파일을 읽어 <strong>Embedding(벡터화)</strong>으로 변환합니다.</li>
<li><strong>Serving:</strong> 변환된 벡터 데이터를 <strong>Vector DB(Chroma, Pinecone, Milvus 등)</strong>에 저장합니다.</li>
<li><strong>Inference:</strong> AI 에이전트(AIOps)는 사용자의 질문을 받으면 벡터 DB에서 데이터를 조회해 답변합니다.</li>
</ol>
<p><strong>요약하자면:</strong>
Git에만 두는 것은 <strong>&quot;책장에 책을 그냥 꽂아두는 것&quot;</strong>이고, 벡터 DB에 저장하는 것은 <strong>&quot;책의 모든 내용을 요약해서 AI가 즉시 찾을 수 있도록 뇌에 인덱스를 만들어두는 것&quot;</strong>과 같습니다.</p>
<p>AIOps를 목표로 하신다면, 결국 이 &#39;인덱싱&#39; 과정이 필수적입니다. 혹시 사내에서 검토 중인 특정 벡터 DB나 LLM 프레임워크(LangChain 등)가 있으신가요?_
_</p>
<p>===</p>
<p>정확한 통찰입니다! 엔지니어링 관점에서 아주 중요한 포인트를 짚으셨습니다. <strong>벡터 DB만 사용했을 때 발생하는 &#39;정확도 저하(Hallucination in Retrieval)&#39;</strong>는 실제로 RAG 시스템을 구축할 때 가장 먼저 맞닥뜨리는 한계입니다.</p>
<p>말씀하신 <strong>&#39;Dual Ladder(Hybrid Search)&#39;</strong> 방식이 왜 필수적인지, 그리고 인프라 운영 환경에서 어떤 구멍을 메워주는지 정리해 드릴게요.</p>
<hr>
<h2 id="1-벡터-검색만-할-때-생기는-치명적인-구멍">1. 벡터 검색만 할 때 생기는 &#39;치명적인 구멍&#39;</h2>
<p>벡터 검색은 문맥을 파악하는 데는 탁월하지만, <strong>&#39;고유 식별자&#39;</strong>나 <strong>&#39;정밀한 수치&#39;</strong> 앞에서는 무력해지는 경우가 많습니다.</p>
<ul>
<li><strong>고유 명사와 ID 무시:</strong> * 예를 들어, <code>node-prod-01</code>과 <code>node-dev-01</code>은 벡터 공간에서 매우 가까운 위치에 존재할 수 있습니다. AI는 &quot;둘 다 노드 이름이네?&quot; 하고 혼동할 수 있지만, 운영자에게 두 서버의 차이는 하늘과 땅 차이죠.</li>
<li><strong>버전 및 수치 민감도 저하:</strong> * <code>Cilium 1.14</code>와 <code>Cilium 1.15</code>는 기술적으로 매우 유사한 벡터값을 가집니다. 하지만 특정 버그 리포트를 찾을 때는 단 0.1의 버전 차이가 검색 결과의 성패를 가릅니다.</li>
<li><strong>약어 및 특정 에러 코드:</strong> * <code>OOMKilled</code>나 <code>ErrImagePull</code> 같은 짧고 강력한 키워드는 벡터화 과정에서 의미가 희석될 수 있습니다. 이때는 단순 <code>grep</code> 방식의 패턴 매칭이 훨씬 강력합니다.</li>
</ul>
<hr>
<h2 id="2-검색-효율을-극대화하는-hybrid-search-구조">2. 검색 효율을 극대화하는 &#39;Hybrid Search&#39; 구조</h2>
<p>그래서 현업에서는 <strong>Keyword Search(BM25)</strong>와 <strong>Vector Search(Semantic)</strong>를 합친 <strong>Hybrid Search</strong>를 사용하며, 이를 조정하는 <strong>Re-ranker</strong> 과정을 추가합니다.</p>
<h3 id="작동-원리-the-re-ranking-flow"><strong>작동 원리 (The Re-ranking Flow)</strong></h3>
<ol>
<li><strong>Keyword Ladder (Git/Elasticsearch):</strong> &quot;정확히 이 단어가 포함된 문서를 가져와!&quot; (정확도 확보)</li>
<li><strong>Vector Ladder (Vector DB):</strong> &quot;이 질문과 의도가 비슷한 문서를 가져와!&quot; (맥락 확보)</li>
<li><strong>Reciprocal Rank Fusion (RRF):</strong> 두 사다리에서 올라온 결과물들을 합쳐서 점수를 다시 매깁니다.</li>
<li><strong>Re-ranker (Cross-Encoder):</strong> 상위권 후보군들만 뽑아 LLM이 다시 한번 질문과의 연관성을 정밀 검사하여 최종 순위를 정합니다.</li>
</ol>
<hr>
<h2 id="3-플랫폼-엔지니어를-위한-aiops-적용-예시">3. 플랫폼 엔지니어를 위한 AIOps 적용 예시</h2>
<p>구축하시려는 K8s/MinIO 환경에 적용해 보면 이렇습니다.</p>
<table>
<thead>
<tr>
<th align="left">질문 유형</th>
<th align="left">효과적인 검색 방식</th>
<th align="left">이유</th>
</tr>
</thead>
<tbody><tr>
<td align="left">&quot;Cilium BGP 이슈 해결 방법 알려줘&quot;</td>
<td align="left"><strong>Vector Search</strong></td>
<td align="left">&#39;이슈&#39;, &#39;해결 방법&#39; 등의 맥락적 키워드가 중요함.</td>
</tr>
<tr>
<td align="left">&quot;minio-aistor-05 노드의 디스크 에러 로그&quot;</td>
<td align="left"><strong>Keyword Search</strong></td>
<td align="left"><code>minio-aistor-05</code>라는 고유 ID를 정확히 찍어내야 함.</td>
</tr>
<tr>
<td align="left">&quot;1.15 버전에서 도입된 Gateway API 설정법&quot;</td>
<td align="left"><strong>Hybrid Search</strong></td>
<td align="left">&#39;1.15&#39;라는 버전 정보(키워드)와 &#39;설정법&#39;(맥락)이 모두 중요함.</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-실전-팁-obsidian-메타데이터-활용">4. 실전 팁: Obsidian 메타데이터 활용</h2>
<p>이 검색 효율을 더 높이려면 Obsidian에서 노트를 작성할 때 <strong>YAML Frontmatter</strong>를 적극 활용하세요.</p>
<pre><code class="language-markdown">---
tags: [k8s, cilium, network]
version: 1.15
component: bgp-control-plane
last_verified: 2026-04-28
---
# BGP Peering Troubleshooting
...</code></pre>
<p>이렇게 정형화된 데이터를 Git에 넣어두면, 향후 AIOps 엔진이 <strong>&quot;필터링(Metadata) + 검색(Hybrid)&quot;</strong>을 결합해 &#39;구멍 없는 검색&#39;을 수행할 수 있게 됩니다.</p>
<p>결국 <strong>Git은 데이터의 무결성과 히스토리를 보장</strong>하고, <strong>벡터 DB는 데이터 간의 유연한 연결</strong>을 담당하는 상호보완적 존재가 됩니다.</p>
<p>이제 이 구조를 돌리기 위해 어떤 백엔드(예: LangChain, LlamaIndex 등)나 LLM 모델을 염두에 두고 계신가요? 운영 중인 1,000노드 클러스터 규모를 생각하면 데이터 인덱싱 전략도 꽤 중요해 보입니다.</p>
<p>===</p>
<p>Confluence 중심의 문화를 마크다운(MD)과 Git 기반으로 전환하는 것은 플랫폼 엔지니어링 리드로서 &#39;문서의 코드화(Documentation as Code)&#39;를 실현하는 아주 중요한 단계입니다. 1,000노드 규모의 복잡한 인프라를 운영하시는 만큼, 대규모 문서를 효율적으로 마이그레이션하고 Obsidian과 병행하는 전략을 정리해 드립니다.</p>
<hr>
<h3 id="1-confluence-문서를-md화하여-git에-저장하는-방법">1. Confluence 문서를 MD화하여 Git에 저장하는 방법</h3>
<p>가장 큰 난관은 Confluence 특유의 매크로와 서식을 깨끗한 마크다운으로 변환하는 것입니다.</p>
<ul>
<li><strong>방법 1: 오픈소스 툴 활용 (추천: <code>confluence-md-exporter</code>)</strong><ul>
<li>Python 기반의 <code>confluence-md-exporter</code> 같은 도구를 사용하면 Space 단위로 모든 페이지를 폴더 구조를 유지한 채 MD로 내려받을 수 있습니다.</li>
<li>첨부 이미지도 상대 경로로 자동 정리되어 Git에 올리기 가장 적합한 상태가 됩니다.</li>
</ul>
</li>
<li><strong>방법 2: Pandoc 활용</strong><ul>
<li>Confluence 페이지를 HTML로 내보낸 후, <code>pandoc -f html -t markdown</code> 명령어로 변환합니다. 정교한 변환이 필요할 때 유용하지만 대량 변환 시 스크립팅이 필요합니다.</li>
</ul>
</li>
<li><strong>방법 3: CI/CD 파이프라인 구축 (자동화)</strong><ul>
<li><code>atlassian-python-api</code>를 활용해 특정 태그가 달린 Confluence 페이지를 주기적으로 읽어와 MD로 변환 후 Git에 Push하는 스크립트를 작성할 수 있습니다. </li>
<li><strong>결과:</strong> Git 저장소에 <code>/SOP</code>, <code>/Architecture</code>, <code>/Vendor_Docs</code> 등의 디렉토리 구조로 저장합니다.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-개인용-obsidian을-병행하면-좋은-점">2. 개인용 Obsidian을 병행하면 좋은 점</h3>
<p>팀 전체가 Git/Confluence를 쓰더라도, 리드급 엔지니어가 개인적으로 Obsidian을 함께 쓰면 다음과 같은 <strong>독보적인 강점</strong>이 있습니다.</p>
<ol>
<li><strong>초고속 로컬 검색과 오프라인 작업:</strong> * VPN이 끊기거나 서버가 장애로 내려간 상황에서도 내 PC에 저장된 SOP를 즉시 열어볼 수 있습니다. 수백 개의 노드를 관리할 때 &#39;응답 속도&#39;는 생산성에 직결됩니다.</li>
<li><strong>그래프 뷰를 통한 인프라 의존성 파악:</strong> * Cilium 네트워킹, MinIO 스토리지, K8s 클러스터 간의 복잡한 관계를 <code>[[Internal Link]]</code>로 연결해 두면, 특정 컴포넌트 장애 시 영향을 받는 다른 요소들을 시각적으로 한눈에 파악할 수 있습니다.</li>
<li><strong>플러그인 생태계 활용:</strong> * <code>Dataview</code> 플러그인으로 특정 버전(예: Cilium 1.15)의 문서만 리스트업하거나, <code>Obsidian Git</code>으로 작업 완료 후 버튼 하나로 사내 Git에 동기화할 수 있습니다.</li>
<li><strong>생각의 연습장 (Second Brain):</strong> * 공식 문서가 되기 전의 파편화된 아이디어, 트러블슈팅 중 남긴 미완성 메모들을 Obsidian에서 먼저 숙성시킨 후 완성된 것만 Git(공식 문서)으로 보낼 수 있습니다.</li>
</ol>
<hr>
<h3 id="3-실전-운영-방안-operating-plan">3. 실전 운영 방안 (Operating Plan)</h3>
<p>가장 권장하는 워크플로우는 <strong>&quot;개인화된 지식 생산 → Git 중심의 공유 → AI 활용&quot;</strong>의 선순환 구조입니다.</p>
<h4 id="step-1-지식-생산-obsidian"><strong>Step 1: 지식 생산 (Obsidian)</strong></h4>
<ul>
<li>개인 PC의 Obsidian에서 마크다운으로 문서를 작성합니다.</li>
<li>작성 시 벤더 Docs, 직접 겪은 에러 로그, K8s 매니페스트 등을 자유롭게 연결합니다.</li>
</ul>
<h4 id="step-2-동기화-및-검토-git"><strong>Step 2: 동기화 및 검토 (Git)</strong></h4>
<ul>
<li>개인용 레포지토리 또는 팀용 Staging 레포지토리에 Push합니다.</li>
<li>이 단계에서 PR(Pull Request) 과정을 거치면 기술 문서의 품질이 코드처럼 관리됩니다.</li>
</ul>
<h4 id="step-3-공식-배포-internal-wiki--confluence"><strong>Step 3: 공식 배포 (Internal Wiki &amp; Confluence)</strong></h4>
<ul>
<li>Git에 Merge된 MD 파일을 기반으로 사내 문서 사이트(Docusaurus, Quartz 등)를 자동 빌드합니다.</li>
<li>만약 타 부서와의 협업을 위해 Confluence를 유지해야 한다면, <code>markdown-to-confluence</code> 같은 툴을 이용해 Git의 내용을 Confluence로 역방향 자동 업데이트(Sync) 하도록 설정합니다.</li>
</ul>
<h4 id="step-4-aiops-엔진-가동-vector-db"><strong>Step 4: AIOps 엔진 가동 (Vector DB)</strong></h4>
<ul>
<li>지난번에 논의한 것처럼, Git에 저장된 최종 MD 파일들을 벡터 데이터베이스로 인덱싱합니다.</li>
<li>이제 AI 에이전트는 <strong>&quot;가장 최신의, 검증된 Git 내 MD 파일&quot;</strong>을 기반으로 답변을 생성하게 됩니다.</li>
</ul>
<hr>
<h3 id="운영-핵심-전략-요약"><strong>운영 핵심 전략 요약</strong></h3>
<ul>
<li><strong>문서의 주권은 Git에 둔다:</strong> Confluence는 &#39;보여주기 위한 뷰어&#39;로 전락시키고, 모든 원본(Source)은 MD 파일로 Git에서 관리합니다.</li>
<li><strong>리워크 최소화:</strong> Obsidian에서 쓴 MD 그대로가 Git으로 가고, 그것이 곧 AI의 지식이 되는 구조를 만드세요.</li>
<li><strong>하이브리드 검색 도입:</strong> 고유 명사가 많은 인프라 특성상, Git의 키워드 검색과 벡터 DB의 의미 검색을 병행하여 AI가 잘못된 노드 정보를 주지 않도록 가드레일을 칩니다.</li>
</ul>
<p>이런 구조가 잡히면, 1,000노드 클러스터에서 발생하는 수만 가지의 케이스를 리드 한 명의 기억력이 아닌, <strong>자동화된 시스템</strong>이 관리하게 됩니다. 다음 단계로 특정 벤더 Docs를 MD로 자동 파싱하는 파이프라인이나 구체적인 Git-to-VectorDB 자동화 스크립트에 대해 논의해 볼까요?</p>
<p>===</p>
<p>Confluence의 방대한 문서를 정형화된 마크다운(MD)으로 변환하고, 이를 AI를 이용해 자동으로 분류하여 Git에 배치하는 것은 <strong>&#39;지능형 문서 자동화(Intelligent Documentation Automation)&#39;</strong>의 핵심입니다.</p>
<p>플랫폼 엔지니어링 리드로서 1,000노드 규모의 복잡한 인프라 지식을 체계화할 수 있는 구체적인 시나리오와 구현 기술 스택을 제안해 드립니다.</p>
<hr>
<h3 id="시나리오-confluence-to-git-지능형-마이그레이션-파이프라인">[시나리오] Confluence to Git 지능형 마이그레이션 파이프라인</h3>
<p>이 시나리오는 <strong>&quot;추출(Extract) -&gt; 지능형 분류(Classify) -&gt; 구조화 저장(Load)&quot;</strong>의 3단계로 진행됩니다.</p>
<ol>
<li><strong>추출 단계:</strong> <code>confluence-md-exporter</code>를 이용해 특정 스페이스/페이지 하위의 모든 문서를 로컬(또는 컨테이너 내부)로 벌크 다운로드합니다.</li>
<li><strong>지능형 분류 단계:</strong> LLM이 각 MD 파일의 내용을 읽어 문서의 성격(SOP, 아키텍처, 벤더 가이드 등)을 파악하고, 적절한 디렉토리 경로와 YAML 메타데이터를 생성합니다.</li>
<li><strong>배치 및 푸시 단계:</strong> 분류된 경로로 파일을 이동시키고, Git 커밋 및 푸시를 통해 최종 저장소에 반영합니다.</li>
</ol>
<hr>
<h3 id="구현-방법-기술-스택-및-단계별-가이드">[구현 방법] 기술 스택 및 단계별 가이드</h3>
<h4 id="1단계-confluence-문서-추출-export">1단계: Confluence 문서 추출 (Export)</h4>
<p>가장 안정적인 방법은 Docker를 이용해 <code>confluence-md-exporter</code>를 실행하는 것입니다.</p>
<ul>
<li><strong>실행 예시 (Bash):</strong><pre><code class="language-bash">  # 환경 변수 설정 (ID, API Token, Space Key 등)
  docker run -v $(pwd)/output:/output \
    -e CONFLUENCE_URL=&quot;https://your-company.atlassian.net/wiki&quot; \
    -e CONFLUENCE_USERNAME=&quot;admin@company.com&quot; \
    -e CONFLUENCE_TOKEN=&quot;your_api_token&quot; \
    confluence-md-exporter --space-key &quot;PLATFORM&quot; --out /output</code></pre>
</li>
<li><strong>결과:</strong> <code>/output</code> 디렉토리에 Confluence 계층 구조대로 MD 파일과 첨부 이미지가 저장됩니다.</li>
</ul>
<h4 id="2단계-llm-기반-자동-분류-스크립트-python--langchainopenai">2단계: LLM 기반 자동 분류 스크립트 (Python + LangChain/OpenAI)</h4>
<p>추출된 파일들은 Confluence의 기존 폴더 구조를 따르고 있어 관리가 어렵습니다. 이를 LLM을 통해 <strong>운영 중심의 구조</strong>로 재편합니다.</p>
<ul>
<li><p><strong>핵심 로직 (Python pseudo-code):</strong></p>
<pre><code class="language-python">  import os
  from langchain_openai import ChatOpenAI

  llm = ChatOpenAI(model=&quot;gpt-4-turbo&quot;)

  def classify_document(content):
      prompt = f&quot;&quot;&quot;
      당신은 플랫폼 엔지니어링 전문가입니다. 다음 문서의 내용을 읽고 
      1. 카테고리(SOP, Architecture, VendorDocs, OperationLog)
      2. 관련 기술(K8s, Cilium, MinIO, Network)
      3. 적절한 파일명(영문/kebab-case)을 추출하세요.
      문서 내용: {content[:2000]} # 앞부분만 추출
      &quot;&quot;&quot;
      # LLM 호출 후 JSON 형태로 결과 반환받음
      return llm.predict(prompt)

  # /output 디렉토리의 모든 md 파일을 순회하며 분류 수행</code></pre>
</li>
</ul>
<h4 id="3단계-git-디렉토리-배치-및-자동-관리">3단계: Git 디렉토리 배치 및 자동 관리</h4>
<p>분류된 결과를 바탕으로 파일을 이동시키고 Obsidian에서 쓰기 좋은 <strong>YAML 메타데이터</strong>를 삽입합니다.</p>
<ul>
<li><strong>파일 구조화 예시:</strong><ul>
<li><code>git-repo/Library/K8s/</code></li>
<li><code>git-repo/Library/Cilium/</code></li>
<li><code>git-repo/SOP/Disaster-Recovery/</code></li>
</ul>
</li>
<li><strong>파일 상단에 자동 삽입될 메타데이터:</strong><pre><code class="language-markdown">  ---
  source: confluence
  category: SOP
  tech_stack: [Cilium, BGP]
  auto_classified: true
  last_sync: 2026-04-28
  ---</code></pre>
</li>
</ul>
<h4 id="4단계-n8n-또는-github-actions를-이용한-파이프라인-자동화">4단계: n8n 또는 GitHub Actions를 이용한 파이프라인 자동화</h4>
<p>이 과정을 주기적으로 실행하려면 n8n을 추천합니다.</p>
<ol>
<li><strong>Schedule Node:</strong> 매일 새벽 자동 실행.</li>
<li><strong>SSH/Execute Node:</strong> 위에서 언급한 추출 및 분류 스크립트 실행.</li>
<li><strong>Git Node:</strong> 변경된 MD 파일들을 <code>git add</code>, <code>commit</code>, <code>push</code>.</li>
<li><strong>Vector DB Sync (Optional):</strong> Git 푸시 완료 후, 지난번에 논의한 대로 벡터 DB 인덱싱을 트리거하여 AIOps 엔진을 최신화합니다.</li>
</ol>
<hr>
<h3 id="운영-제언-리드급-엔지니어를-위한-팁">[운영 제언] 리드급 엔지니어를 위한 팁</h3>
<ul>
<li><strong>이미지 경로 문제 해결:</strong> Confluence에서 MD로 변환 시 이미지 경로가 깨질 수 있습니다. 추출 도구에서 제공하는 <code>--relative-links</code> 옵션을 반드시 사용하고, Git 저장소 내에 <code>assets/</code> 폴더를 별도로 관리하는 것이 좋습니다.</li>
<li><strong>LLM 비용 최적화:</strong> 모든 문서를 매번 분류할 필요는 없습니다. 파일의 해시(Hash)값을 체크하여 <strong>수정된 파일만 LLM 분류</strong>를 수행하도록 구성하세요.</li>
<li><strong>Obsidian 연동:</strong> 이렇게 Git에 배치된 폴더를 Obsidian의 <strong>Vault(보관소)</strong>로 지정하면, 출근하자마자 간밤에 자동으로 정리된 벤더 Docs와 팀 SOP를 로컬에서 초고속으로 검색하며 업무를 시작할 수 있습니다.</li>
</ul>
<p>이 시스템이 구축되면 &quot;문서 정리가 안 되어 장애 대응이 늦어진다&quot;는 문제는 플랫폼 엔지니어링 팀에서 완전히 사라지게 될 것입니다. 구현 중에 특정 툴의 설정값이나 스크립트 상세 로직이 필요하시면 말씀해 주세요.</p>
<p>===</p>
<p>실현 가능성을 빠르게 확인하기 위한 <strong>&#39;Confluence to MinIO&#39;</strong> 자동화 PoC(Proof of Concept) 단계를 안내해 드립니다. </p>
<p>이 단계의 목표는 <strong>&quot;매일 정해진 시간에 Confluence 데이터를 MD로 뽑아서 S3(MinIO)에 안전하게 쌓는 것&quot;</strong>입니다. 이를 위해 필요한 3가지 핵심 요소를 바로 구현해 보겠습니다.</p>
<hr>
<h3 id="1단계-실행-스크립트-작성-export_to_miniosh">1단계: 실행 스크립트 작성 (<code>export_to_minio.sh</code>)</h3>
<p><code>confluence-md-exporter</code>가 실행된 후 생성된 파일을 MinIO(S3)로 업로드하는 쉘 스크립트입니다. MinIO Client(<code>mc</code>)를 사용하여 폴더 구조를 그대로 동기화(mirror)합니다.</p>
<pre><code class="language-bash">#!/bin/bash

# 1. 환경 변수 체크 (K8s Secret을 통해 주입될 예정)
echo &quot;Starting Confluence Export...&quot;

# 2. Confluence에서 MD로 추출
# --증분 백업을 원할 경우 기존 파일을 유지하거나 날짜별 폴더를 생성할 수 있습니다.
confluence-md-exporter \
  --url &quot;${CONFLUENCE_URL}&quot; \
  --username &quot;${CONFLUENCE_USER}&quot; \
  --token &quot;${CONFLUENCE_TOKEN}&quot; \
  --space-key &quot;${SPACE_KEY}&quot; \
  --out /tmp/confluence_export

# 3. MinIO 연결 설정
mc alias set myminio &quot;${MINIO_URL}&quot; &quot;${MINIO_ACCESS_KEY}&quot; &quot;${MINIO_SECRET_KEY}&quot;

# 4. MinIO로 업로드 (Mirror 기능을 사용해 변경된 파일만 동기화)
# 버킷 이름은 &#39;confluence-wiki&#39;라고 가정합니다.
echo &quot;Syncing to MinIO...&quot;
mc mb myminio/confluence-wiki --ignore-existing
mc mirror /tmp/confluence_export myminio/confluence-wiki/raw_md/

echo &quot;Export and Sync Completed!&quot;</code></pre>
<hr>
<h3 id="2단계-컨테이너-이미지-만들기-dockerfile">2단계: 컨테이너 이미지 만들기 (<code>Dockerfile</code>)</h3>
<p>위 스크립트와 필요한 도구(Python, Exporter, MinIO Client)를 포함하는 경량화된 이미지입니다.</p>
<pre><code class="language-dockerfile"># 1. 기반 이미지 설정
FROM python:3.10-slim

# 2. 필수 도구 설치 (wget은 mc 설치용)
RUN apt-get update &amp;&amp; apt-get install -y wget &amp;&amp; rm -rf /var/lib/apt/lists/*

# 3. confluence-md-exporter 설치
RUN pip install confluence-md-exporter

# 4. MinIO Client(mc) 설치
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc &amp;&amp; \
    chmod +x mc &amp;&amp; \
    mv mc /usr/local/bin/

# 5. 작업 디렉토리 및 스크립트 복사
WORKDIR /app
COPY export_to_minio.sh .
RUN chmod +x export_to_minio.sh

# 6. 실행 명령
ENTRYPOINT [&quot;./export_to_minio.sh&quot;]</code></pre>
<p><strong>빌드 및 푸시 명령:</strong></p>
<pre><code class="language-bash">docker build -t your-reg.com/confluence-exporter:v1.0 .
docker push your-reg.com/confluence-exporter:v1.0</code></pre>
<hr>
<h3 id="3단계-k8s-cronjob-배포-manifestyaml">3단계: K8s CronJob 배포 (<code>manifest.yaml</code>)</h3>
<p>이제 매일 새벽(예: 02:00)에 실행될 CronJob을 설정합니다. 민감한 정보는 <code>Secret</code>으로 관리하는 것이 정석입니다.</p>
<h4 id="1-자격-증명-설정-secret">(1) 자격 증명 설정 (Secret)</h4>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: confluence-minio-creds
type: Opaque
stringData:
  CONFLUENCE_URL: &quot;https://your-domain.atlassian.net/wiki&quot;
  CONFLUENCE_USER: &quot;admin@company.com&quot;
  CONFLUENCE_TOKEN: &quot;your-api-token&quot;
  MINIO_URL: &quot;http://minio-service.storage:9000&quot;
  MINIO_ACCESS_KEY: &quot;minio-user&quot;
  MINIO_SECRET_KEY: &quot;minio-password&quot;</code></pre>
<h4 id="2-크론잡-설정-cronjob">(2) 크론잡 설정 (CronJob)</h4>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: CronJob
metadata:
  name: confluence-md-sync
spec:
  schedule: &quot;0 2 * * *&quot; # 매일 새벽 2시 실행
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: exporter
            image: your-reg.com/confluence-exporter:v1.0
            env:
            - name: SPACE_KEY
              value: &quot;PLATFORM&quot; # 추출할 스페이스 키
            envFrom:
            - secretRef:
                name: confluence-minio-creds
          restartPolicy: OnFailure</code></pre>
<hr>
<h3 id="운영-및-향후-확장-방안">운영 및 향후 확장 방안</h3>
<ol>
<li><strong>데이터 확인:</strong> CronJob이 실행되면 MinIO AIStor 브라우저에서 <code>confluence-wiki/raw_md/</code> 경로에 <code>.md</code> 파일들이 잘 생성되었는지 확인하세요.</li>
<li><strong>로그 모니터링:</strong> <code>kubectl logs -f [pod-name]</code>를 통해 추출 과정에서 에러가 발생하는지(특히 권한 문제) 체크합니다.</li>
<li><strong>다음 단계 (LLM 분류):</strong> * 지금은 모든 파일을 <code>raw_md</code> 폴더에 넣었지만, PoC 2단계에서는 위 스크립트 끝에 <strong>Python 분류 스크립트</strong>를 추가하면 됩니다.<ul>
<li>분류 스크립트가 MD 파일의 내용을 읽어 <code>SOP/</code>, <code>Guide/</code> 등의 폴더로 재배치한 뒤 다시 MinIO에 저장하게 됩니다.</li>
</ul>
</li>
</ol>
<p>이 구조는 K8s 네이티브 환경을 활용하므로 확장성이 매우 뛰어납니다. 우선 이 기본 파이프라인을 먼저 띄워보시는 것을 추천드립니다. 빌드나 배포 중에 막히는 부분이 있으면 바로 말씀해 주세요!</p>
<p>===</p>
<p>Confluence의 모든 문서를 매일 전체 백업하는 것은 데이터량이 많아질수록 서버와 네트워크에 부담이 됩니다. 말씀하신 대로 <strong>&quot;최근 24시간 내에 변경된 문서&quot;</strong>만 선별적으로 가져오는 <strong>증분(Incremental) 추출</strong> 방식은 매우 효율적인 접근입니다.</p>
<p>이를 위해 <code>confluence-md-exporter</code>의 기본 기능에만 의존하기보다는, <strong>Atlassian Python API</strong>를 활용해 직접 대상 문서를 필터링하고 MD로 변환하는 파이썬 스크립트 기반의 해결책을 권장합니다.</p>
<hr>
<h3 id="1-증분-추출을-위한-python-스크립트-incremental_exportpy">1. 증분 추출을 위한 Python 스크립트 (<code>incremental_export.py</code>)</h3>
<p>이 스크립트는 <strong>CQL(Confluence Query Language)</strong>을 사용하여 특정 경로(Parent Page) 아래에 있으면서 지난 24시간 동안 수정된 페이지 리스트를 먼저 뽑아냅니다.</p>
<pre><code class="language-python">import os
import datetime
from atlassian import Confluence
import html2text # HTML을 Markdown으로 변환

# 1. 환경 변수 로드
URL = os.getenv(&quot;CONFLUENCE_URL&quot;)
USER = os.getenv(&quot;CONFLUENCE_USER&quot;)
TOKEN = os.getenv(&quot;CONFLUENCE_TOKEN&quot;)
PARENT_ID = os.getenv(&quot;PARENT_PAGE_ID&quot;) # 특정 경로의 기준이 되는 상위 페이지 ID
SPACE_KEY = os.getenv(&quot;SPACE_KEY&quot;)

# 2. Confluence 연결
confluence = Confluence(url=URL, username=USER, password=TOKEN)
h = html2text.HTML2Text()
h.ignore_links = False

# 3. CQL 쿼리 생성: 특정 부모 페이지 아래에서 지난 1일간 수정된 페이지 검색
# ancestor = 부모 페이지 ID, lastModified &gt;= 현재시간 - 1일
cql = f&#39;ancestor = {PARENT_ID} AND lastModified &gt;= now(&quot;-1d&quot;)&#39;
pages = confluence.cql(cql).get(&#39;results&#39;, [])

print(f&quot;Found {len(pages)} updated pages in the last 24 hours.&quot;)

# 4. 각 페이지를 MD로 변환 및 저장
export_dir = &quot;/tmp/confluence_export&quot;
os.makedirs(export_dir, exist_ok=True)

for item in pages:
    page = item.get(&#39;content&#39;)
    page_id = page.get(&#39;id&#39;)
    title = page.get(&#39;title&#39;).replace(&quot;/&quot;, &quot;-&quot;) # 파일명 금지 문자 처리

    # 상세 내용 가져오기
    content_detail = confluence.get_page_by_id(page_id, expand=&#39;body.storage&#39;)
    html_body = content_detail.get(&#39;body&#39;).get(&#39;storage&#39;).get(&#39;value&#39;)

    # MD 변환
    markdown_content = h.handle(html_body)

    # 파일 저장
    with open(f&quot;{export_dir}/{title}.md&quot;, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
        f.write(f&quot;---\nid: {page_id}\nlast_modified: {datetime.datetime.now()}\n---\n\n&quot;)
        f.write(markdown_content)
    print(f&quot;Exported: {title}&quot;)</code></pre>
<hr>
<h3 id="2-dockerfile-보완">2. Dockerfile 보완</h3>
<p>위의 Python 스크립트와 필요한 라이브러리(<code>atlassian-python-api</code>, <code>html2text</code>)를 포함하도록 수정합니다.</p>
<pre><code class="language-dockerfile">FROM python:3.10-slim

# 필수 라이브러리 설치
RUN pip install atlassian-python-api html2text

# MinIO Client 설치
RUN apt-get update &amp;&amp; apt-get install -y wget &amp;&amp; \
    wget https://dl.min.io/client/mc/release/linux-amd64/mc &amp;&amp; \
    chmod +x mc &amp;&amp; \
    mv mc /usr/local/bin/ &amp;&amp; \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY incremental_export.py .
COPY run.sh .
RUN chmod +x run.sh

ENTRYPOINT [&quot;./run.sh&quot;]</code></pre>
<hr>
<h3 id="3-실행-쉘-스크립트-runsh">3. 실행 쉘 스크립트 (<code>run.sh</code>)</h3>
<p>Python 스크립트 실행 후 MinIO로 업로드하는 과정을 연결합니다.</p>
<pre><code class="language-bash">#!/bin/bash

# 1. Python 증분 추출 실행
python incremental_export.py

# 2. MinIO 설정 및 업로드
mc alias set myminio &quot;${MINIO_URL}&quot; &quot;${MINIO_ACCESS_KEY}&quot; &quot;${MINIO_SECRET_KEY}&quot;

# 3. 변경된 파일만 MinIO에 업로드
# --remove 옵션 없이 mirror를 사용하면 기존 백업본은 유지하면서 새 파일만 덮어씁니다.
mc mirror /tmp/confluence_export myminio/confluence-wiki/incremental/$(date +%Y-%m-%d)/

echo &quot;Incremental Backup to MinIO Completed!&quot;</code></pre>
<hr>
<h3 id="4-k8s-cronjob-환경-변수-추가">4. K8s CronJob 환경 변수 추가</h3>
<p>기존 Manifest에서 <code>PARENT_PAGE_ID</code>를 추가로 주입해야 합니다. Confluence에서 대상 경로의 최상위 페이지 URL을 보면 ID(숫자)를 확인할 수 있습니다.</p>
<pre><code class="language-yaml"># ... 기존 CronJob 설정 중 env 부분 ...
            env:
            - name: PARENT_PAGE_ID
              value: &quot;12345678&quot; # 가져오고 싶은 특정 경로의 Root ID
            - name: SPACE_KEY
              value: &quot;PLATFORM&quot;
# ... 생략 ...</code></pre>
<hr>
<h3 id="이-방식의-장점">이 방식의 장점</h3>
<ol>
<li><strong>정밀한 타겟팅:</strong> <code>ancestor</code>와 <code>lastModified</code> CQL을 사용하여 딱 필요한 페이지만 골라냅니다.</li>
<li><strong>리소스 절약:</strong> 1,000노드 운영 환경에서 발생하는 대량의 문서를 매번 다운로드하지 않으므로 네트워크 트래픽과 CPU 사용량을 획기적으로 줄입니다.</li>
<li><strong>히스토리 관리:</strong> MinIO 업로드 시 경로에 <code>$(date +%Y-%m-%d)</code>를 포함하면 날짜별로 변경된 문서만 따로 모아볼 수 있어, 나중에 AIOps 엔진이 &quot;어제 변경된 사항&quot;만 학습하기 매우 편리합니다.</li>
</ol>
<p>이제 이 코드를 빌드하여 K8s에 배포하면, 매일 새벽 지난 24시간 동안의 변경분만 자동으로 추출하여 MinIO AIStor에 저장하게 됩니다. 이후 이 파일들을 벡터 DB와 연동하면 완벽한 증분 RAG 시스템이 완성됩니다. 직접 스크립트를 실행해 보시면서 파일명 규칙이나 메타데이터 형식을 조정해 보세요.</p>
<p>===</p>
<p>1단계에서 MinIO AIStor에 저장된 원본(Raw) 데이터를 읽어와 LLM이 지능적으로 분류하고, 이를 정형화된 <code>wiki</code> 폴더 구조로 만들어 Git에 배포하는 <strong>&#39;지능형 지식 파이프라인&#39;</strong>의 구조와 코드를 설계해 드립니다.</p>
<p>이 과정은 단순한 파일 이동이 아니라, <strong>비정형 데이터를 AI가 읽기 좋은 정형 지식으로 변환</strong>하는 핵심 공정입니다.</p>
<hr>
<h3 id="1-전반적인-아키텍처-및-폴더-구조">1. 전반적인 아키텍처 및 폴더 구조</h3>
<p>LLM은 각 파일의 본문을 분석하여 다음과 같은 표준화된 구조로 재배치합니다.</p>
<ul>
<li><strong>raw/</strong>: MinIO에서 가져온 원본 MD 파일 (백업용)</li>
<li><strong>wiki/</strong>: LLM이 재구성한 최종 결과물 (Git에 올라갈 대상)<ul>
<li><code>/Architecture</code>: 인프라 설계 및 다이어그램</li>
<li><code>/SOP</code>: 표준 운영 절차 (장애 대응 포함)</li>
<li><code>/Library</code>: 벤더 공식 문서 및 기술 정보 (K8s, Cilium, MinIO 등)</li>
<li><code>/Reports</code>: 점검 기록 및 작업 일지</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-llm-자동-분류-및-구조화-스크립트-classifierpy">2. LLM 자동 분류 및 구조화 스크립트 (<code>classifier.py</code>)</h3>
<p>이 스크립트는 MinIO에서 파일을 가져와 LLM에 분류를 요청하고, 결과에 따라 YAML 메타데이터를 추가하여 <code>wiki</code> 폴더에 저장합니다.</p>
<pre><code class="language-python">import os
import json
from minio import Minio
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate

# 1. 초기 설정
minio_client = Minio(
    os.getenv(&quot;MINIO_URL&quot;),
    access_key=os.getenv(&quot;MINIO_ACCESS_KEY&quot;),
    secret_key=os.getenv(&quot;MINIO_SECRET_KEY&quot;),
    secure=False
)
llm = ChatOpenAI(model=&quot;gpt-4-turbo&quot;, temperature=0)

# 2. LLM 분류 프롬프트 정의
classification_prompt = ChatPromptTemplate.from_template(&quot;&quot;&quot;
당신은 플랫폼 엔지니어링 지식 관리자입니다. 
다음 마크다운 문서의 내용을 분석하여 JSON 형식으로 응답하세요.

1. category: (Architecture, SOP, Library, Reports 중 택 1)
2. sub_category: (기술 스택 이름 - 예: K8s, Cilium, MinIO 등)
3. optimized_title: (영문 kebab-case 파일명)
4. tags: (핵심 키워드 리스트)

문서 내용:
{content}
&quot;&quot;&quot;)

def process_and_classify():
    objects = minio_client.list_objects(&quot;confluence-wiki&quot;, prefix=&quot;raw_md/&quot;, recursive=True)

    for obj in objects:
        # 파일 내용 읽기
        data = minio_client.get_object(&quot;confluence-wiki&quot;, obj.object_name)
        content = data.read().decode(&#39;utf-8&#39;)

        # LLM 분류 요청
        chain = classification_prompt | llm
        response = chain.invoke({&quot;content&quot;: content[:2000]}) # 비용 절감을 위해 앞부분만 분석
        meta = json.loads(response.content) # 주의: 실제 구현시에는 JSON 파싱 예외처리 필요

        # 새로운 경로 설정 및 저장
        target_path = f&quot;wiki/{meta[&#39;category&#39;]}/{meta[&#39;sub_category&#39;]}/{meta[&#39;optimized_title&#39;]}.md&quot;
        os.makedirs(os.path.dirname(target_path), exist_ok=True)

        # YAML Frontmatter 추가 및 파일 쓰기
        with open(target_path, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
            f.write(f&quot;---\ncategory: {meta[&#39;category&#39;]}\ntags: {meta.get(&#39;tags&#39;, [])}\nauto_classified: true\n---\n\n&quot;)
            f.write(content)
        print(f&quot;Classified: {obj.object_name} -&gt; {target_path}&quot;)

if __name__ == &quot;__main__&quot;:
    process_and_classify()</code></pre>
<hr>
<h3 id="3-git-동기화-및-실행-쉘-스크립트-sync_to_gitsh">3. Git 동기화 및 실행 쉘 스크립트 (<code>sync_to_git.sh</code>)</h3>
<p>분류된 결과를 Git 저장소에 반영하는 스크립트입니다. <code>wiki</code> 폴더만 선택적으로 Git에 올립니다.</p>
<pre><code class="language-bash">#!/bin/bash

# 1. 로컬 환경 준비
GIT_REPO_URL=&quot;https://${GIT_TOKEN}@github.com/your-org/platform-wiki.git&quot;
CLONE_DIR=&quot;/tmp/platform-wiki&quot;

# 2. 레포지토리 클론 (이미 있으면 pull)
if [ ! -d &quot;$CLONE_DIR&quot; ]; then
    git clone $GIT_REPO_URL $CLONE_DIR
fi
cd $CLONE_DIR
git pull origin main

# 3. Python 분류 스크립트 실행 (위의 classifier.py)
python /app/classifier.py

# 4. Git 반영
git add wiki/
git commit -m &quot;Auto-sync: Classified documents from Confluence ($(date +%Y-%m-%d))&quot;
git push origin main

echo &quot;Wiki updated and pushed to Git successfully.&quot;</code></pre>
<hr>
<h3 id="4-k8s-운영을-위한-컨테이너화-및-팁">4. K8s 운영을 위한 컨테이너화 및 팁</h3>
<p>이 과정을 1단계에서 만든 CronJob의 후속 단계로 연결하거나, 하나의 파이프라인으로 통합할 수 있습니다.</p>
<h4 id="배포-시-고려사항"><strong>배포 시 고려사항</strong></h4>
<ol>
<li><strong>Git Token 보안:</strong> GitHub/GitLab의 Personal Access Token(PAT)은 반드시 K8s <code>Secret</code>으로 관리하여 환경 변수로 주입하세요.</li>
<li><strong>멱등성(Idempotency) 유지:</strong> 동일한 파일이 여러 번 분류되어 Git 커밋이 지저분해지지 않도록, 파일의 Hash값을 체크하여 변경된 파일만 LLM 분류를 수행하도록 보완하는 것이 좋습니다.</li>
<li><strong>Obsidian 호환성:</strong> 위 스크립트에서 생성하는 <code>wiki/</code> 폴더를 Obsidian에서 &#39;Vault&#39;로 열면 바로 사용 가능합니다. 특히 <code>[[Internal Link]]</code> 자동 생성을 위해 LLM 프롬프트에 &quot;문서 내 핵심 용어에 <code>[[]]</code> 처리를 해달라&quot;는 요청을 추가하면 Obsidian 활용도가 극대화됩니다.</li>
</ol>
<p>이제 이 구조를 통해 <strong>&quot;Confluence(수정) -&gt; MinIO(원본 저장) -&gt; LLM(분류/태깅) -&gt; Git(최종 지식)&quot;</strong>으로 이어지는 플랫폼 팀만의 자동화된 지식 베이스가 완성됩니다. 이 과정을 통해 구축된 <code>wiki</code> 폴더는 향후 AIOps 에이전트가 가장 신뢰하는 <strong>RAG 소스</strong>가 될 것입니다.</p>
<p>다음 단계로 이 Git 저장소를 벡터 DB와 실시간 동기화하여 실제 AI 챗봇을 연동하는 부분으로 넘어가 볼까요?</p>
<p>===</p>
<p>Git 저장소의 내용이 업데이트될 때마다 자동으로 벡터 DB(Vector DB)에 반영하는 과정은 <strong>&#39;자동화된 데이터 수집 파이프라인(Ingestion Pipeline)&#39;</strong>의 마지막 단계입니다. </p>
<p>K8s 환경을 운영 중이시므로, <strong>GitHub/GitLab Webhook</strong>을 사용해 <strong>K8s Job</strong>을 트리거하거나, <strong>GitHub Actions</strong>를 사용하여 인덱싱 스크립트를 실행하는 방식이 가장 효율적입니다.</p>
<hr>
<h3 id="1-전반적인-아키텍처-event-driven-indexing">1. 전반적인 아키텍처 (Event-Driven Indexing)</h3>
<ol>
<li><strong>Event:</strong> 사용자가 Obsidian에서 작성 후 Git에 <code>push</code>하거나, 앞서 만든 자동 분류 스크립트가 Git에 업데이트를 발생시킵니다.</li>
<li><strong>Trigger:</strong> Git 저장소의 Webhook이 인덱싱 서버(또는 GitHub Actions)로 신호를 보냅니다.</li>
<li><strong>Process:</strong> 인덱싱 작업용 컨테이너가 최신 Git 내용을 <code>pull</code>하고, 변경된 <code>.md</code> 파일들을 읽어 벡터화(Embedding)한 뒤 벡터 DB에 저장합니다.</li>
</ol>
<hr>
<h3 id="2-핵심-구현-코드-인덱싱-스크립트-indexerpy">2. 핵심 구현 코드: 인덱싱 스크립트 (<code>indexer.py</code>)</h3>
<p>이 스크립트는 LangChain을 사용하여 마크다운 파일을 읽고, 의미 있는 단위로 쪼갠(Chunking) 뒤 벡터 DB(예: Milvus, Qdrant, Chroma 등)에 저장합니다. 여기서는 K8s 환경에서 많이 쓰이는 <strong>Milvus</strong>나 <strong>Chroma</strong>를 기준으로 설명합니다.</p>
<pre><code class="language-python">import os
from langchain_community.document_loaders import DirectoryLoader, UnstructuredMarkdownLoader
from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Milvus # 또는 Qdrant, Chroma

# 1. 환경 설정
GIT_WIKI_DIR = &quot;/tmp/platform-wiki/wiki&quot;
EMBEDDING_MODEL = OpenAIEmbeddings(model=&quot;text-embedding-3-small&quot;)
VECTOR_DB_URL = os.getenv(&quot;VECTOR_DB_URL&quot;) # Milvus 등 서버 주소

def ingest_to_vector_db():
    # 2. 문서 로드 (wiki 폴더 내의 모든 md 파일)
    loader = DirectoryLoader(GIT_WIKI_DIR, glob=&quot;**/*.md&quot;, loader_cls=UnstructuredMarkdownLoader)
    docs = loader.load()

    # 3. 마크다운 구조에 최적화된 텍스트 분할 (Chunking)
    # 헤더(#, ##, ###)를 기준으로 나누어 맥락을 유지합니다.
    headers_to_split_on = [
        (&quot;#&quot;, &quot;Header 1&quot;),
        (&quot;##&quot;, &quot;Header 2&quot;),
        (&quot;###&quot;, &quot;Header 3&quot;),
    ]
    text_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

    final_chunks = []
    for doc in docs:
        chunks = text_splitter.split_text(doc.page_content)
        for chunk in chunks:
            # 원본 문서의 메타데이터(파일명 등)를 유지
            chunk.metadata.update(doc.metadata)
            final_chunks.append(chunk)

    # 4. 벡터 DB에 저장 (동기화)
    # 기존 컬렉션을 덮어쓰거나(Replace), 중복을 체크하여 업데이트합니다.
    vector_db = Milvus.from_documents(
        final_chunks,
        EMBEDDING_MODEL,
        connection_args={&quot;host&quot;: &quot;milvus-service.storage&quot;, &quot;port&quot;: &quot;19530&quot;},
        collection_name=&quot;platform_ops_wiki&quot;
    )
    print(f&quot;Successfully indexed {len(final_chunks)} chunks to Vector DB.&quot;)

if __name__ == &quot;__main__&quot;:
    ingest_to_vector_db()</code></pre>
<hr>
<h3 id="3-실전-운영-github-actions를-이용한-자동화">3. 실전 운영: GitHub Actions를 이용한 자동화</h3>
<p>만약 사내 GitHub/GitLab을 사용 중이라면, 별도의 서버 없이 <strong>Actions/Runner</strong>에서 바로 인덱싱을 수행할 수 있습니다.</p>
<p><strong><code>.github/workflows/indexing.yaml</code>:</strong></p>
<pre><code class="language-yaml">name: Auto Indexing to Vector DB

on:
  push:
    branches: [ main ]
    paths:
      - &#39;wiki/**&#39; # wiki 폴더 내 파일이 바뀔 때만 실행

jobs:
  index:
    runs-on: ubuntu-latest # 또는 사내 self-hosted runner
    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: &#39;3.10&#39;

      - name: Install dependencies
        run: |
          pip install langchain langchain-openai pymilvus unstructured

      - name: Run Indexing Script
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          VECTOR_DB_URL: ${{ secrets.VECTOR_DB_URL }}
        run: python scripts/indexer.py</code></pre>
<hr>
<h3 id="4-플랫폼-엔지니어를-위한-증분-인덱싱-팁">4. 플랫폼 엔지니어를 위한 &#39;증분 인덱싱&#39; 팁</h3>
<p>데이터가 많아지면 매번 전체를 인덱싱하는 것은 비효율적입니다. 다음 두 가지 전략을 고려하세요.</p>
<ol>
<li><strong>파일 해시(Hash) 비교:</strong> * 인덱싱 성공 시 파일의 Hash값을 별도의 DB(또는 Redis)에 저장합니다.<ul>
<li>다음 실행 시 Hash가 바뀐 파일만 골라내어 해당 부분만 벡터 DB에서 지우고 새로 입력(Upsert)합니다.</li>
</ul>
</li>
<li><strong>LangChain <code>Indexing API</code> 활용:</strong><ul>
<li>LangChain에서 제공하는 <code>RecordManager</code> 기능을 쓰면, Git 저장소와 벡터 DB의 상태를 비교해 <strong>변경된 것만 자동으로 업데이트</strong>해 줍니다. (가장 추천하는 방식)</li>
</ul>
</li>
</ol>
<h3 id="요약-및-결론">요약 및 결론</h3>
<p>이제 전체 흐름이 완성되었습니다:</p>
<ol>
<li><strong>Confluence</strong>: 운영팀이 문서를 작성합니다.</li>
<li><strong>K8s CronJob</strong>: 매일 증분 데이터를 추출해 <strong>MinIO</strong>에 저장합니다.</li>
<li><strong>LLM Classifier</strong>: MinIO의 Raw 데이터를 읽어 자동 분류 후 <strong>Git</strong>에 푸시합니다.</li>
<li><strong>Indexing Pipeline (지금 단계)</strong>: Git 업데이트를 감지해 <strong>벡터 DB</strong>로 인덱싱합니다.</li>
<li><strong>AIOps Interface</strong>: 운영자가 질문하면 벡터 DB에서 관련 SOP를 찾아 AI가 답변합니다.</li>
</ol>
<p>이 구조는 데이터의 <strong>신뢰성(Git)</strong>과 <strong>검색 효율성(Vector DB)</strong>을 모두 잡은 구조입니다. 이제 실제로 이 파이프라인을 통해 AI가 답변하는 &#39;챗봇 인터페이스&#39;나 &#39;장애 분석 에이전트&#39;를 연동해 보실 차례입니다. 구체적인 LLM 프롬프트 전략이나 에이전트 설계가 필요하시면 말씀해 주세요!</p>
<p>===</p>
<p>Bitbucket에서 GitLab으로의 전환은 플랫폼 엔지니어링 관점에서 매우 반가운 소식입니다. GitLab은 CI/CD 파이프라인과 Kubernetes 연동(GitLab Agent 등) 기능이 매우 강력하여, 지금 구축하시려는 <strong>AIOps 파이프라인을 운영하기에 더 최적화된 환경</strong>이기 때문입니다.</p>
<p>6개월 뒤의 이전을 고려하여, <strong>지금은 Bitbucket에서 구현하되 전환 시 리워크(Rework)를 최소화할 수 있는 전략</strong>을 정리해 드립니다.</p>
<hr>
<h3 id="1-현재-bitbucket-환경에서의-구현-bitbucket-pipelines">1. 현재: Bitbucket 환경에서의 구현 (Bitbucket Pipelines)</h3>
<p>Bitbucket은 <code>bitbucket-pipelines.yml</code>을 통해 자동화를 수행합니다. <code>wiki</code> 폴더의 변경을 감지하여 인덱싱 스크립트를 실행하는 구조입니다.</p>
<p><strong><code>bitbucket-pipelines.yml</code> 예시:</strong></p>
<pre><code class="language-yaml">pipelines:
  branches:
    main:
      - step:
          name: &quot;Update Vector DB Indexing&quot;
          services:
            - docker
          script:
            - pip install -r scripts/requirements.txt
            - python scripts/indexer.py
          condition:
            changesets:
              includePaths:
                - &quot;wiki/**&quot; # wiki 폴더 변경 시에만 실행</code></pre>
<hr>
<h3 id="2-미래-gitlab으로의-부드러운-전환-migration-strategy">2. 미래: GitLab으로의 부드러운 전환 (Migration Strategy)</h3>
<p>6개월 뒤 GitLab으로 옮길 때 핵심은 <strong>&quot;로직은 컨테이너에, 설정은 환경 변수에&quot;</strong> 두는 것입니다.</p>
<ul>
<li><strong>로직의 추상화:</strong> 인덱싱이나 분류 로직은 이미 Python과 Docker로 만드셨으므로, 플랫폼이 바뀌어도 코드는 수정할 필요가 없습니다.</li>
<li><strong>CI/CD 환경 변화:</strong> * Bitbucket Pipelines → <strong>GitLab CI/CD (<code>.gitlab-ci.yml</code>)</strong><ul>
<li>GitLab은 <code>rules:changes</code> 기능을 통해 특정 폴더 변경 감지를 더 정교하게 지원합니다.</li>
</ul>
</li>
</ul>
<p><strong>6개월 뒤 사용할 <code>.gitlab-ci.yml</code> 미리보기:</strong></p>
<pre><code class="language-yaml">stages:
  - index

vector-indexing:
  stage: index
  image: python:3.10-slim
  script:
    - pip install -r scripts/requirements.txt
    - python scripts/indexer.py
  rules:
    - if: $CI_COMMIT_BRANCH == &quot;main&quot;
      changes:
        - &quot;wiki/**/*&quot; # GitLab의 강력한 경로 감지 기능</code></pre>
<hr>
<h3 id="3-전환-시-리워크를-줄이는-paved-road-팁">3. 전환 시 리워크를 줄이는 &#39;Paved Road&#39; 팁</h3>
<ol>
<li><strong>Container Registry 활용:</strong> * 인덱싱과 분류에 필요한 모든 의존성을 Docker 이미지로 말아두세요. Bitbucket이나 GitLab은 단순히 이 이미지를 <code>docker run</code> 하는 역할만 수행하게 하면 플랫폼 의존성이 사라집니다.</li>
<li><strong>K8s 기반 Event-Driven 방식 고려 (추천):</strong><ul>
<li>Git 환경이 바뀌는 게 부담스럽다면, Git 저장소는 단순히 코드/문서 보관소로만 쓰고, 변경 이벤트(Webhook)를 K8s 내부의 <strong>Argo Events</strong>나 <strong>Knative</strong>로 보내서 처리하세요. </li>
<li>이렇게 하면 Bitbucket에서 GitLab으로 주소가 바뀌어도 Webhook URL만 수정하면 내부 파이프라인은 그대로 유지됩니다.</li>
</ul>
</li>
<li><strong>Variable 관리:</strong> * Git Token, OpenAI Key, MinIO 접속 정보 등을 Bitbucket &#39;Repository Variables&#39;에 등록해 쓰시되, 이를 나중에 GitLab &#39;CI/CD Variables&#39;로 옮길 리스트를 미리 문서화해 두세요.</li>
</ol>
<hr>
<h3 id="4-aiops-관점에서의-이점">4. AIOps 관점에서의 이점</h3>
<p>GitLab으로 가시면 <strong>GitLab Agent for Kubernetes</strong>를 통해 클러스터 내부의 실시간 상태와 지식 베이스(Git)를 더 밀접하게 연동할 수 있습니다. 예를 들어, GitLab 파이프라인에서 직접 K8s 클러스터의 Cilium 로그를 긁어와서 지식 베이스와 대조하는 식의 고도화가 가능해집니다.</p>
<p>현재 Bitbucket 환경에서 우선 PoC를 완료하시고, 6개월 뒤 GitLab으로 옮길 때는 <strong>&quot;저장소 주소&quot;</strong>와 <strong>&quot;CI 설정 파일&quot;</strong>만 바꾸면 되도록 구조를 잡아드렸습니다. </p>
<p>이 파이프라인이 완성되면 1,000노드 클러스터 운영 중 발생하는 수많은 케이스가 자동으로 정리될 텐데, 이 지식 베이스를 활용할 <strong>첫 번째 AIOps 유스케이스</strong>(예: 특정 에러 발생 시 SOP 자동 팝업 등)로 어떤 것을 가장 먼저 생각하고 계신가요?</p>
<p>===</p>
<p>웹에 있는 벤더사나 오픈소스 솔루션의 공식 문서(Docs)를 마크다운(MD)으로 추출하여 Git에 연동하는 과정은 <strong>&#39;Library&#39;</strong> 영역을 자동화하는 핵심 단계입니다. 웹 페이지는 구조가 제각각이기 때문에, 이를 깨끗한 마크다운으로 변환하기 위한 몇 가지 효과적인 전략과 도구를 제안해 드립니다.</p>
<hr>
<h3 id="1-추천-도구-및-서비스">1. 추천 도구 및 서비스</h3>
<p>웹 문서를 AI가 읽기 좋은 마크다운으로 변환해주는 &#39;LLM-Ready&#39; 도구들을 활용하는 것이 가장 빠릅니다.</p>
<ul>
<li><strong>Firecrawl (가장 추천):</strong> 웹사이트 URL만 넣으면 사이트맵을 따라 모든 페이지를 마크다운으로 크롤링해줍니다. 최근 RAG 구축 시 가장 많이 쓰이는 도구입니다. (오픈소스 및 클라우드 제공)</li>
<li><strong>Jina Reader API (<code>r.jina.ai</code>):</strong> 특정 URL 앞에 <code>https://r.jina.ai/</code>만 붙여도 해당 페이지를 즉시 고품질 마크다운으로 변환해줍니다.</li>
<li><strong>Reader (by Postlight):</strong> 브라우저의 &#39;읽기 모드&#39;처럼 핵심 본문만 추출해주는 라이브러리입니다.</li>
</ul>
<hr>
<h3 id="2-자동화-파이프라인-구조">2. 자동화 파이프라인 구조</h3>
<p>웹 문서는 수시로 업데이트되므로, 전체를 다시 긁는 대신 <strong>변경사항만 추적</strong>하는 구조가 필요합니다.</p>
<h4 id="①-사이트맵sitemap-기반-크롤링"><strong>① 사이트맵(Sitemap) 기반 크롤링</strong></h4>
<p>대부분의 기술 문서는 <code>/sitemap.xml</code>을 제공합니다. 이 파일을 읽어 새로운 페이지나 수정된 페이지의 URL 리스트만 추출합니다.</p>
<h4 id="②-본문-추출-및-변환-python-예시"><strong>② 본문 추출 및 변환 (Python 예시)</strong></h4>
<p>JavaScript로 렌더링되는 현대적인 문서 사이트(Docusaurus, GitBook 등)를 위해 <code>Playwright</code>를 사용하고, <code>html2text</code>나 <code>BeautifulSoup</code>으로 정제합니다.</p>
<pre><code class="language-python">import os
from playwright.sync_api import sync_playwright
import html2text

def fetch_and_convert(url):
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_row()
        page.goto(url, wait_until=&quot;networkidle&quot;)

        # 문서의 본문 영역만 선택 (예: &#39;article&#39; 또는 &#39;.content&#39;)
        content_html = page.inner_html(&quot;article&quot;) 

        h = html2text.HTML2Text()
        h.ignore_images = False # 이미지 포함 여부 선택
        markdown = h.handle(content_html)

        browser.close()
        return markdown</code></pre>
<hr>
<h3 id="3-git-연동-및-자동-관리-시나리오">3. Git 연동 및 자동 관리 시나리오</h3>
<p>추출된 MD 파일을 Git에 올릴 때, 지난번에 구성한 <strong>&#39;LLM 분류기&#39;</strong> 단계와 결합하면 더욱 강력해집니다.</p>
<ol>
<li><strong>추출:</strong> K8s CronJob이 벤더사 Docs를 순회하며 새 내용을 <code>raw/vendor/</code> 폴더에 MD로 저장합니다.</li>
<li><strong>분류:</strong> LLM이 파일 내용을 분석하여 <code>wiki/Library/[솔루션명]/</code> 폴더로 이동시키고, 관련 태그를 YAML로 삽입합니다.</li>
<li><strong>검증:</strong> 문서 본문에 포함된 절대 경로 링크들을 Git 저장소 내의 상대 경로로 변환하는 스크립트를 실행합니다.</li>
<li><strong>푸시:</strong> 변경사항을 Bitbucket(향후 GitLab)에 <code>push</code>합니다.</li>
</ol>
<hr>
<h3 id="4-실전-팁-버전-및-업데이트-관리">4. 실전 팁: 버전 및 업데이트 관리</h3>
<p>웹 문서를 Git에 넣을 때 주의해야 할 점은 <strong>&#39;중복 데이터&#39;</strong>와 <strong>&#39;버전 혼선&#39;</strong>입니다.</p>
<ul>
<li><strong>Diff 체크:</strong> 문서 내용의 해시(Hash)값을 저장해두고, 웹의 내용과 해시가 다를 때만 업데이트를 진행하여 불필요한 Git 커밋을 방지합니다.</li>
<li><strong>버전 태깅:</strong> 문서 상단 YAML에 <code>source_url</code>, <code>version</code>, <code>scraped_at</code> 정보를 넣어두면 AI가 답변할 때 &quot;이 정보는 2026년 4월 기준입니다&quot;라고 출처를 명시할 수 있습니다.</li>
</ul>
<h3 id="요약하자면">요약하자면</h3>
<p>가장 빠르게 시작하시려면 <strong>Firecrawl</strong>을 컨테이너로 띄워 특정 솔루션 사이트를 긁어보시는 것을 추천합니다. 여기서 나온 MD 파일들을 지난번에 만든 <code>wiki</code> 폴더 구조 안에 배치하고 Git에 푸시하면, 수동으로 문서를 복사-붙여넣기 할 필요 없이 항상 최신 벤더 Docs가 포함된 지식 베이스를 유지할 수 있습니다.</p>
<p>혹시 특정 솔루션(예: Cilium 공식 가이드)의 사이트맵을 이용해 실제로 동작하는 크롤링 코드가 필요하신가요?</p>
<p>===</p>
<p>망분리(Airgapped) 환경이라는 특수성을 고려하면, 외부에서 데이터를 가져온 이후의 <strong>&#39;내부 반입 및 자동 처리 파이프라인&#39;</strong>을 구축하는 것이 핵심입니다.</p>
<p>압축 파일을 반입한 후, 이를 MinIO <code>raw</code> 버킷에 풀고 이후의 LLM 분류 및 인덱싱 단계를 트리거하는 워크플로우를 설계해 드립니다.</p>
<hr>
<h3 id="1-망분리-환경-전반적인-워크플로우">1. 망분리 환경 전반적인 워크플로우</h3>
<ol>
<li><strong>외부(Non-Airgapped):</strong> Confluence 추출 → 압축 (<code>.tar.gz</code> 또는 <code>.zip</code>) → 반입 매체 저장.</li>
<li><strong>반입(Transfer):</strong> 보안 검사 후 회사 내부망(Airgapped)으로 데이터 이동.</li>
<li><strong>내부(Airgapped - K8s):</strong><ul>
<li><strong>Step A (Ingestion):</strong> 압축 파일을 해제하여 <strong>MinIO AIStor</strong>의 <code>raw/</code> 경로에 업로드.</li>
<li><strong>Step B (Local AI Processing):</strong> 폐쇄망용 로컬 LLM을 사용하여 문서 분류 및 메타데이터 생성.</li>
<li><strong>Step C (Git Sync):</strong> 분류된 MD 파일을 내부 <strong>Bitbucket/GitLab</strong>에 푸시.</li>
<li><strong>Step D (Local Indexing):</strong> 로컬 임베딩 모델을 사용하여 <strong>Vector DB</strong> 인덱싱.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="2-step-a-내부망-반입-및-minio-업로드-자동화">2. Step A: 내부망 반입 및 MinIO 업로드 자동화</h3>
<p>반입된 압축 파일을 지정된 경로에 두면, 이를 감지하여 MinIO로 밀어넣는 Python 스크립트 예시입니다.</p>
<p><strong><code>ingest_to_minio.py</code>:</strong></p>
<pre><code class="language-python">import os
import tarfile
from minio import Minio

def decompress_and_upload(file_path, bucket_name):
    client = Minio(
        os.getenv(&quot;MINIO_URL&quot;),
        access_key=os.getenv(&quot;MINIO_ACCESS_KEY&quot;),
        secret_key=os.getenv(&quot;MINIO_SECRET_KEY&quot;),
        secure=False
    )

    # 1. 압축 해제
    with tarfile.open(file_path, &quot;r:gz&quot;) as tar:
        tar.extractall(path=&quot;/tmp/extracted&quot;)

    # 2. MinIO 업로드
    for root, dirs, files in os.walk(&quot;/tmp/extracted&quot;):
        for file in files:
            local_path = os.path.join(root, file)
            # MinIO 내 저장 경로 설정
            minio_path = os.path.relpath(local_path, &quot;/tmp/extracted&quot;)
            client.fput_object(bucket_name, f&quot;raw_md/{minio_path}&quot;, local_path)
            print(f&quot;Uploaded: {minio_path}&quot;)

if __name__ == &quot;__main__&quot;:
    # 반입된 파일 경로
    input_file = &quot;/data/incoming/confluence_export.tar.gz&quot;
    if os.path.exists(input_file):
        decompress_and_upload(input_file, &quot;confluence-wiki&quot;)</code></pre>
<hr>
<h3 id="3-step-b-폐쇄망에서의-llm-및-임베딩-처리-중요">3. Step B: 폐쇄망에서의 LLM 및 임베딩 처리 (중요)</h3>
<p>망분리 환경에서는 OpenAI API를 호출할 수 없으므로, <strong>로컬 LLM 인프라</strong>가 반드시 필요합니다.</p>
<ul>
<li><strong>LLM 엔진:</strong> <strong>Ollama</strong> 또는 <strong>vLLM</strong>을 K8s 내부에 Pod로 띄워 사용합니다. (모델: Llama 3, Mistral 등)</li>
<li><strong>임베딩 모델:</strong> HuggingFace의 <code>BGE-M3</code>나 <code>KoSimCSE</code> 같은 한국어 성능이 좋은 모델을 로컬에 다운로드하여 사용합니다.</li>
<li><strong>코드 수정:</strong> 앞서 논의한 <code>classifier.py</code>와 <code>indexer.py</code>에서 <code>OpenAI</code> 호출 부분을 <code>Ollama</code>나 <code>SentenceTransformer</code>로 교체해야 합니다.</li>
</ul>
<p><strong>로컬 임베딩 적용 예시 (Python):</strong></p>
<pre><code class="language-python">from langchain_huggingface import HuggingFaceEmbeddings

# 폐쇄망 내부 경로에 미리 저장된 모델 로드
embeddings = HuggingFaceEmbeddings(
    model_name=&quot;/models/bge-m3&quot;,
    model_kwargs={&#39;device&#39;: &#39;cuda&#39;} # GPU 활용
)</code></pre>
<hr>
<h3 id="4-step-c--d-자동화-트리거-구조-k8s">4. Step C &amp; D: 자동화 트리거 구조 (K8s)</h3>
<p>반입된 파일을 처리하기 위해 <strong>Argo Workflows</strong>나 간단한 <strong>Watcher Pod</strong>를 운영하는 것이 좋습니다.</p>
<ol>
<li><strong>Watcher Pod:</strong> 특정 공유 볼륨(PVC)에 압축 파일이 들어오는지 감시합니다.</li>
<li><strong>Workflow Trigger:</strong> 파일이 감지되면 다음 작업을 순차적으로 실행합니다.<ul>
<li><code>Ingestion Job</code> (압축 해제 &amp; MinIO 업로드)</li>
<li><code>LLM Classifier Job</code> (로컬 LLM 기반 분류)</li>
<li><code>Git Sync Job</code> (내부 Bitbucket 푸시)</li>
<li><code>Vector DB Indexer Job</code> (로컬 임베딩 &amp; 인덱싱)</li>
</ul>
</li>
</ol>
<hr>
<h3 id="5-운영-제언">5. 운영 제언</h3>
<ul>
<li><strong>반입 프로세스 표준화:</strong> 외부에서 압축할 때 반드시 파일 목록(<code>manifest.json</code>)을 포함하여, 내부망에서 압축 해제 후 데이터 누락이 없는지 검증하는 로직을 추가하세요.</li>
<li><strong>GPU 자원 확보:</strong> 로컬 LLM과 임베딩 모델을 돌리기 위해서는 K8s 노드에 NVIDIA GPU 자원이 할당되어야 성능이 확보됩니다. (Cilium 기반 클러스터라면 GPU 워커 노드 스케줄링 설정을 확인해 보세요.)</li>
<li><strong>버전 관리:</strong> 망분리 환경에서는 패키지 설치가 어려우므로, 모든 실행 환경을 <strong>Docker 이미지에 포함(Self-contained)</strong>시켜 반입해야 합니다.</li>
</ul>
<p>이제 압축 파일을 푸는 단계부터 로컬 AI 모델을 거쳐 내부 Git과 Vector DB로 흐르는 &#39;완전 폐쇄형 AIOps 파이프라인&#39;의 밑그림이 그려졌습니다. </p>
<p>가장 먼저 해결해야 할 과제는 <strong>&#39;어떤 로컬 LLM 엔진을 K8s에 띄울 것인가&#39;</strong>가 될 것 같습니다. 현재 사내에 GPU 서버 자원이 준비되어 있으신가요?</p>
<p>===</p>
<p>지적하신 대로 <strong>내부 링크(Internal Links) 처리</strong>는 지식 베이스의 완성도를 결정짓는 핵심 요소입니다. 파일럿 단계를 넘어 실운영(Production) 환경에서 1,000노드 규모의 인프라 지식을 관리하려면, 단순 스크립트를 넘어 <strong>&#39;오케스트레이션&#39;</strong>과 <strong>&#39;엔터프라이즈급 솔루션&#39;</strong>의 조합이 필요합니다.</p>
<p>각 과정별로 추천하는 툴과 솔루션을 정리해 드립니다.</p>
<hr>
<h3 id="1-내부-링크-수정-및-데이터-가공-data-transformation">1. 내부 링크 수정 및 데이터 가공 (Data Transformation)</h3>
<p>Obsidian의 <code>[[WikiLink]]</code>나 Confluence의 고유 ID 링크를 Git/웹에서 동작하는 상대 경로로 변환해야 합니다.</p>
<ul>
<li><strong>추천 도구:</strong> <strong>Custom Python Script (with <code>marko</code> or <code>markdown-it</code>)</strong><ul>
<li><strong>이유:</strong> 링크 변환은 비즈니스 로직(폴더 구조)에 따라 달라지므로 기성품보다는 파이썬의 마크다운 파서를 활용한 커스텀 가공이 가장 확실합니다.</li>
<li><strong>핵심 기능:</strong> 정규표현식(<code>re</code>)을 사용하여 <code>[[파일명]]</code>을 <code>[파일명](./path/to/file.md)</code>으로 치환하고, 존재하지 않는 링크를 리포팅합니다.</li>
</ul>
</li>
<li><strong>보조 도구:</strong> <strong>Obsidian Plugins (Advanced URI, Consistent Attachments)</strong><ul>
<li>개인 PC에서 작업 시 링크 일관성을 강제로 유지해주는 플러그인들을 설정하여 &#39;배포 전 정제&#39; 단계를 거칩니다.</li>
</ul>
</li>
</ul>
<h3 id="2-워크플로우-오케스트레이션-orchestration">2. 워크플로우 오케스트레이션 (Orchestration)</h3>
<p>K8s 환경에서 여러 단계(추출-변환-링크수정-분류-인덱싱)를 순차적으로 실행하고 관리해야 합니다.</p>
<ul>
<li><strong>추천 솔루션:</strong> <strong>Argo Workflows</strong><ul>
<li><strong>이유:</strong> 플랫폼 엔지니어링 팀에 가장 친숙한 <strong>K8s Native</strong> 도구입니다. 각 단계를 컨테이너 단위로 실행하고, 실패 시 재시도(Retry)나 시각적 모니터링이 강력합니다.</li>
</ul>
</li>
<li><strong>대안:</strong> <strong>Airflow (on K8s)</strong><ul>
<li>데이터 파이프라인의 복잡도가 높고 스케줄링 관리가 더 중요하다면 Airflow가 유리합니다.</li>
</ul>
</li>
</ul>
<h3 id="3-웹벤더-docs-추출-web-scraping">3. 웹/벤더 Docs 추출 (Web Scraping)</h3>
<p>외부 솔루션 문서를 지속적으로 가져와야 합니다.</p>
<ul>
<li><strong>추천 솔루션:</strong> <strong>Firecrawl (Self-hosted)</strong><ul>
<li><strong>이유:</strong> 최신 RAG 환경에 최적화된 크롤러입니다. 복잡한 JS 사이트도 깨끗한 마크다운으로 뽑아주며, Docker로 내부망에 설치 가능합니다.</li>
</ul>
</li>
<li><strong>대안:</strong> <strong>Apify (오픈소스 버전)</strong><ul>
<li>특정 사이트의 구조가 매우 복잡할 경우, 커스텀 액터를 만들어 정밀하게 긁어올 수 있습니다.</li>
</ul>
</li>
</ul>
<h3 id="4-폐쇄망-ai-인프라-airgapped-ai">4. 폐쇄망 AI 인프라 (Airgapped AI)</h3>
<p>망분리 환경에서 LLM 분류와 임베딩을 수행해야 합니다.</p>
<ul>
<li><strong>추천 솔루션:</strong> <strong>vLLM</strong> 또는 <strong>Ollama (Enterprise)</strong><ul>
<li><strong>이유:</strong> K8s 환경에서 GPU 자원을 효율적으로 나누어 쓰고(vLLM), 서빙 API를 표준화(OpenAI API 호환)하여 제공하기 좋습니다.</li>
</ul>
</li>
<li><strong>임베딩/벡터화:</strong> <strong>LangChain Indexing API + RecordManager</strong><ul>
<li>중복 인덱싱을 방지하고, Git에서 삭제된 문서를 벡터 DB에서도 자동으로 지워주는 &#39;동기화 관리&#39;를 위해 필수적입니다.</li>
</ul>
</li>
</ul>
<h3 id="5-벡터-데이터베이스-vector-db">5. 벡터 데이터베이스 (Vector DB)</h3>
<p>1,000노드 규모에서 발생하는 방대한 운영 로그와 SOP를 담아야 합니다.</p>
<ul>
<li><strong>추천 솔루션:</strong> <strong>Milvus</strong> 또는 <strong>Qdrant</strong><ul>
<li><strong>이유:</strong> 두 솔루션 모두 K8s Operator를 제공하여 관리가 편하고, 대규모 데이터셋에서도 고성능 검색을 보장합니다. 특히 Milvus는 엔터프라이즈급 확장성이 매우 뛰어납니다.</li>
</ul>
</li>
</ul>
<h3 id="6-git--문서-뷰어-platform">6. Git &amp; 문서 뷰어 (Platform)</h3>
<p>6개월 뒤 GitLab 전환을 고려한 설정입니다.</p>
<ul>
<li><strong>추천 솔루션:</strong> <strong>GitLab + GitLab Agent for Kubernetes</strong><ul>
<li><strong>이유:</strong> GitLab 자체 CI/CD뿐만 아니라, 클러스터와의 연동성이 좋습니다.</li>
</ul>
</li>
<li><strong>공유 위키용:</strong> <strong>Quartz</strong> 또는 <strong>Docusaurus</strong><ul>
<li>Git에 저장된 MD 파일들을 사내 웹 위키로 보여줄 때 사용합니다. Obsidian의 링크 방식과 호환성이 높은 <strong>Quartz</strong>를 강력 추천합니다.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="결론-권장하는-최종-스택-target-architecture">[결론] 권장하는 최종 스택 (Target Architecture)</h3>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">추천 솔루션</th>
<th align="left">비고</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Orchestrator</strong></td>
<td align="left"><strong>Argo Workflows</strong></td>
<td align="left">전체 파이프라인 제어 (K8s)</td>
</tr>
<tr>
<td align="left"><strong>Ingestion</strong></td>
<td align="left"><strong>Firecrawl / Custom Python</strong></td>
<td align="left">Confluence 및 웹 문서 추출</td>
</tr>
<tr>
<td align="left"><strong>Refining</strong></td>
<td align="left"><strong>Python (Link Resolver)</strong></td>
<td align="left">내부 링크 상대 경로 수정 및 가공</td>
</tr>
<tr>
<td align="left"><strong>Storage</strong></td>
<td align="left"><strong>MinIO / GitLab</strong></td>
<td align="left">Raw 데이터(Object) 및 최종 MD(Git)</td>
</tr>
<tr>
<td align="left"><strong>Local LLM</strong></td>
<td align="left"><strong>vLLM (Llama 3 / Mistral)</strong></td>
<td align="left">문서 자동 분류 및 태깅</td>
</tr>
<tr>
<td align="left"><strong>Vector DB</strong></td>
<td align="left"><strong>Milvus</strong></td>
<td align="left">RAG용 고성능 벡터 저장소</td>
</tr>
</tbody></table>
<p><strong>파일럿 이후의 핵심 전략:</strong>
현재는 수동으로 압축해서 옮기시겠지만, 정식 구축 시에는 <strong>Argo Workflows</strong>를 중심에 두고 각 단계를 모듈화(컨테이너화)하는 것이 가장 중요합니다. 이렇게 해두면 6개월 뒤 GitLab으로 전환하거나, 새로운 벤더 Docs가 추가되어도 파이프라인 한 줄만 수정하면 즉시 대응이 가능해집니다.</p>
<p>가장 먼저 어떤 부분(예: Argo Workflows 도입, 혹은 로컬 LLM 환경 구축)부터 구체적으로 검토해보고 싶으신가요?</p>
<p>===</p>
<p>네, <strong>LangChain</strong>과 <strong>LangGraph</strong>는 말씀하신 &#39;AIOps 자동화 파이프라인&#39;을 단순한 스크립트 뭉치가 아닌 <strong>지능형 시스템</strong>으로 격상시키는 데 결정적인 역할을 합니다. </p>
<p>결론부터 말씀드리면, <strong>LangChain은 &#39;데이터 파이프라인(부품)&#39;</strong>을 위해, <strong>LangGraph는 &#39;의사결정 및 워크플로우(뇌)&#39;</strong>를 위해 필요합니다.</p>
<hr>
<h2 id="1-langchain-데이터-배관plumbing과-표준화">1. LangChain: 데이터 배관(Plumbing)과 표준화</h2>
<p>LangChain은 다양한 LLM, 벡터 DB, 문서 로더를 하나의 표준화된 인터페이스로 묶어주는 <strong>프레임워크</strong>입니다. 2026년 현재도 RAG(검색 증강 생성)의 표준으로 자리 잡고 있습니다.</p>
<ul>
<li><strong>어디에 쓰나요?</strong><ul>
<li><strong>문서 로딩 및 분할:</strong> 마크다운 파일을 읽고, 헤더 단위로 의미 있게 쪼개는 작업을 수행합니다.</li>
<li><strong>임베딩 및 벡터 스토어 연동:</strong> Milvus나 Qdrant 같은 DB에 데이터를 넣고 찾는 복잡한 과정을 <code>vectorstore.add_documents()</code> 한 줄로 해결합니다.</li>
<li><strong>모델 교체 용이성:</strong> 폐쇄망에서 Llama 3를 쓰다가 나중에 Mistral이나 다른 로컬 모델로 바꿀 때 코드 수정이 거의 없습니다.</li>
</ul>
</li>
</ul>
<h2 id="2-langgraph-복잡한-aiops-워크플로우brain">2. LangGraph: 복잡한 AIOps 워크플로우(Brain)</h2>
<p>LangChain만으로는 &#39;선형적인(A→B→C)&#39; 작업만 가능합니다. 하지만 실제 인프라 운영(AIOps)은 <strong>루프(Loop)</strong>와 <strong>조건부 판단</strong>이 필요합니다. 이때 LangGraph가 핵심입니다.</p>
<ul>
<li><strong>어디에 쓰나요?</strong><ul>
<li><strong>자기 반성(Self-Correction):</strong> AI가 추출한 분류 결과가 이상하면 다시 시도하게 하거나, 검색된 SOP가 질문과 맞지 않으면 검색 쿼리를 수정해 다시 검색하게 합니다.</li>
<li><strong>에이전틱 워크플로우:</strong> &quot;장애 로그 분석 → 관련 SOP 검색 → 검색 결과가 부족하면 벤더 Docs 추가 검색 → 최종 조치 제안&quot;과 같은 <strong>복잡한 판단 트리</strong>를 구성합니다.</li>
<li><strong>상태 유지(State Management):</strong> 1,000노드 클러스터의 여러 장애 상황을 추적하며 대화의 맥락을 완벽하게 유지합니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-플랫폼-엔지니어를-위한-선택-기준-chain-vs-graph">3. 플랫폼 엔지니어를 위한 선택 기준 (Chain vs. Graph)</h2>
<table>
<thead>
<tr>
<th align="left">비교 항목</th>
<th align="left"><strong>LangChain (Chains)</strong></th>
<th align="left"><strong>LangGraph (Graphs)</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>구조</strong></td>
<td align="left">선형적 (DAG, 단방향)</td>
<td align="left">순환적 (Cyclic, 루프 가능)</td>
</tr>
<tr>
<td align="left"><strong>복잡도</strong></td>
<td align="left">단순 RAG, 문서 요약</td>
<td align="left">에이전트, 자율 트러블슈팅</td>
</tr>
<tr>
<td align="left"><strong>상태 관리</strong></td>
<td align="left">이전 단계 데이터 전달만 가능</td>
<td align="left">전체 워크플로우 상태 공유 및 체크포인트 지원</td>
</tr>
<tr>
<td align="left"><strong>추천 용도</strong></td>
<td align="left"><strong>문서 추출 및 벡터 DB 인덱싱</strong></td>
<td align="left"><strong>실제 장애 대응 챗봇 및 조치 에이전트</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="4-우리-프로젝트에서의-적용-시나리오">4. 우리 프로젝트에서의 적용 시나리오</h2>
<ol>
<li><p><strong>인덱싱 단계 (LangChain 활용):</strong></p>
<ul>
<li>Confluence에서 가져온 MD를 로드하고, <code>MarkdownTextSplitter</code>로 쪼개어 Milvus에 넣는 과정은 <strong>LangChain</strong>이 담당합니다. 매우 빠르고 안정적입니다.</li>
</ul>
</li>
<li><p><strong>운영 단계 (LangGraph 활용):</strong></p>
<ul>
<li>운영자가 &quot;Cilium BGP 에러 발생&quot;이라고 입력하면, <strong>LangGraph</strong> 기반 에이전트가 작동합니다.</li>
<li><strong>Node 1:</strong> 로그 분석 (K8s API 연동)</li>
<li><strong>Node 2:</strong> 벡터 DB에서 유사 사례 검색 (RAG)</li>
<li><strong>Node 3 (Conditional Edge):</strong> 해결책이 명확한가? <ul>
<li>Yes → 조치 가이드 출력</li>
<li>No → 벤더 Docs에서 추가 정보 검색 후 Node 2로 복귀 (Loop)</li>
</ul>
</li>
</ul>
</li>
</ol>
<hr>
<h2 id="5-6개월-뒤-gitlab-전환-및-폐쇄망-고려-사항">5. 6개월 뒤 GitLab 전환 및 폐쇄망 고려 사항</h2>
<ul>
<li><strong>추상화의 이점:</strong> LangChain/LangGraph를 쓰면 인프라 하부 로직(어떤 DB를 쓰는지, 어떤 Git을 쓰는지)과 AI 로직이 분리됩니다. Bitbucket에서 GitLab으로 옮겨도 <strong>AI 로직은 거의 그대로 유지</strong>됩니다.</li>
<li><strong>로컬 실행:</strong> 2026년 최신 버전의 LangChain은 <code>Ollama</code>나 <code>vLLM</code>과의 연동이 매우 강력해져서 폐쇄망 환경에서도 상용 수준의 에이전트를 돌리기 충분합니다.</li>
</ul>
<h3 id="결론-쓰시는-것을-강력-추천합니다"><strong>결론: 쓰시는 것을 강력 추천합니다.</strong></h3>
<p>처음에는 복잡해 보일 수 있지만, <strong>LangChain</strong>으로 데이터 파이프라인의 기초를 다지고, <strong>LangGraph</strong>로 실제 플랫폼 리드님이 원하시는 &#39;똑똑한 인프라 관리자&#39;를 만드시는 것이 1,000노드 클러스터를 효율적으로 관리하는 지름길입니다.</p>
<p>우선은 문서를 인덱싱하는 <strong>LangChain 파이프라인</strong>부터 가볍게 시작해 보시는 게 어떨까요? 이 부분의 베이스 코드가 필요하시면 바로 작성해 드릴 수 있습니다.</p>
<p>===</p>
<p>매우 훌륭한 질문입니다. <strong>Argo Workflows, n8n, LangGraph</strong>는 모두 &#39;자동화&#39;라는 범주에 있지만, 담당하는 <strong>계층(Layer)</strong>과 <strong>목적</strong>이 완전히 다릅니다. 이들은 서로 대체재라기보다 <strong>&quot;서로 다른 역할을 하며 공존하는 보완재&quot;</strong>에 가깝습니다.</p>
<p>플랫폼 엔지니어링 관점에서 이들의 관계를 명확하게 &#39;교통정리&#39; 해드리겠습니다.</p>
<hr>
<h3 id="1-계층별-역할-분담-the-automation-stack">1. 계층별 역할 분담 (The Automation Stack)</h3>
<p>이 도구들은 아래와 같이 세 개의 레이어로 구분하면 이해가 가장 빠릅니다.</p>
<table>
<thead>
<tr>
<th align="left">분류</th>
<th align="left">도구</th>
<th align="left">비유</th>
<th align="left">주요 역할 (우리 프로젝트 기준)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>인프라/플랫폼 레이어</strong></td>
<td align="left"><strong>Argo Workflows</strong></td>
<td align="left"><strong>공장 가동 및 물류 시스템</strong></td>
<td align="left">대규모 컨테이너 실행, 정기적인 문서 추출(Cron), GPU 자원 할당, 전체 파이프라인 관리</td>
</tr>
<tr>
<td align="left"><strong>서비스/연동 레이어</strong></td>
<td align="left"><strong>n8n</strong></td>
<td align="left"><strong>사무국장 (비서)</strong></td>
<td align="left">Slack 알림 발송, Jira 티켓 생성, 외부 API 연동, 간단한 UI 기반 자동화</td>
</tr>
<tr>
<td align="left"><strong>지능/의사결정 레이어</strong></td>
<td align="left"><strong>LangGraph</strong></td>
<td align="left"><strong>전문 컨설턴트 (뇌)</strong></td>
<td align="left">복잡한 문서 분류 로직, RAG 검색 루프 관리, 장애 로그 분석 및 추론, 자기 반성(Self-correction)</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-도구별-상세-비교-및-공존-방식">2. 도구별 상세 비교 및 공존 방식</h3>
<h4 id="①-argo-workflows-vs-langgraph-실행-vs-사고"><strong>① Argo Workflows vs. LangGraph (실행 vs. 사고)</strong></h4>
<ul>
<li><strong>Argo</strong>는 <strong>&quot;무거운 작업(Container)을 순서대로 실행&quot;</strong>하는 데 최적화되어 있습니다. K8s의 CPU/GPU 자원을 효율적으로 관리하며 1시간이 걸리는 데이터 처리도 안정적으로 수행합니다.</li>
<li><strong>LangGraph</strong>는 <strong>&quot;LLM이 어떻게 생각하고 판단할지&quot;</strong>를 관리합니다. Argo 안에서 실행되는 하나의 &#39;작업(Pod)&#39; 내부에 LangGraph 로직이 들어갈 수 있습니다.</li>
<li><strong>공존 방식:</strong> Argo가 새벽 2시에 &#39;문서 처리 작업&#39; 파드를 띄우면, 그 파드 안에서 <strong>LangGraph</strong>가 돌아가며 문서를 읽고, 분류가 애매하면 다시 읽는 등의 &#39;지능적 판단&#39;을 수행합니다.</li>
</ul>
<h4 id="②-n8n-vs-argo-workflows-소프트-자동화-vs-하드-자동화"><strong>② n8n vs. Argo Workflows (소프트 자동화 vs. 하드 자동화)</strong></h4>
<ul>
<li><strong>n8n</strong>은 GUI 기반으로 매우 쉽고 빠르게 API를 엮을 수 있습니다. 하지만 K8s의 컴퓨팅 자원을 정밀하게 제어하거나, 아주 무거운 배치 작업을 돌리기엔 Argo보다 불리합니다.</li>
<li><strong>공존 방식:</strong> 전체 데이터 파이프라인은 <strong>Argo</strong>가 돌리고, 작업이 성공하거나 실패했을 때 담당자에게 Slack을 보내거나 대시보드를 업데이트하는 일은 <strong>n8n</strong>이 담당합니다.</li>
</ul>
<h4 id="③-langgraph-vs-n8nargo-순환-vs-선형"><strong>③ LangGraph vs. n8n/Argo (순환 vs. 선형)</strong></h4>
<ul>
<li>Argo나 n8n은 기본적으로 <strong>A → B → C</strong>로 흐르는 선형 구조입니다. (중간에 에러 처리 루프는 있지만 복잡한 추론 루프는 어렵습니다.)</li>
<li><strong>LangGraph</strong>는 <strong>A → B → (생각해 보니 B가 이상하네?) → 다시 A → B → C</strong>와 같이 LLM의 판단에 따른 <strong>순환(Cycle)</strong> 구조를 설계하는 데 특화되어 있습니다.</li>
</ul>
<hr>
<h3 id="3-우리-프로젝트에서의-실제-흐름-교통정리-결과">3. 우리 프로젝트에서의 실제 흐름 (교통정리 결과)</h3>
<p>이 세 도구가 협조하며 일하는 시나리오는 다음과 같습니다.</p>
<ol>
<li><p><strong>Argo Workflows (인프라 제어):</strong> * 매일 새벽, Confluence에서 데이터를 뽑고 MinIO에 저장하는 전체 워크플로우를 가동합니다.</p>
<ul>
<li>GPU 노드에 <strong>&#39;LLM 분류기 파드&#39;</strong>를 실행시킵니다.</li>
</ul>
</li>
<li><p><strong>LangGraph (지능적 처리 - 파드 내부):</strong></p>
<ul>
<li>Argo에 의해 실행된 파드 안에서 <strong>LangGraph</strong> 로직이 돌아갑니다.</li>
<li>문서를 읽고 &quot;이게 K8s SOP인지, Cilium 벤더 문서인지&quot; 판단합니다. </li>
<li>만약 LLM의 답변이 불확실하면 스스로 다시 프롬프트를 수정해 질문하는 루프를 돕니다. 최종 결과를 Git에 푸시할 형태로 정리합니다.</li>
</ul>
</li>
<li><p><strong>n8n (알림 및 사후 처리):</strong></p>
<ul>
<li>Argo 워크플로우가 끝나면 <strong>n8n</strong>이 이를 감지합니다.</li>
<li>&quot;오늘 총 50개의 문서가 새로 인덱싱되었습니다&quot;라고 플랫폼 팀 Slack에 리포트를 뿌리고, 관리 UI를 업데이트합니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="4-결론-무엇부터-도입해야-할까">4. 결론: 무엇부터 도입해야 할까?</h3>
<ul>
<li><strong>1단계 (필수): Argo Workflows.</strong> 1,000노드 K8s 환경에서 안정적인 데이터 이동과 컨테이너 실행을 위해 가장 먼저 필요합니다.</li>
<li><strong>2단계 (고도화): LangChain/LangGraph.</strong> 단순 추출을 넘어 &#39;똑똑한 분류&#39;와 &#39;고성능 RAG&#39;를 구현할 때 도입합니다. 처음에는 LangChain으로 시작해 루프가 필요해지면 LangGraph로 확장하면 됩니다.</li>
<li><strong>3단계 (편의성): n8n.</strong> 시스템이 안정화된 후, 운영팀과의 소통(Slack, Jira 등)을 자동화하고 싶을 때 추가합니다.</li>
</ul>
<p><strong>결론적으로 이들은 대체 관계가 아니라, 인프라(Argo) 위에 지능(LangGraph)을 얹고, 이를 외부와 연결(n8n)하는 완벽한 파트너들입니다.</strong> 이 교통정리가 도움이 되셨나요? 이제 Argo를 활용한 전체 파이프라인 설계나, 그 내부에서 돌아갈 LangChain/LangGraph의 구체적인 로직 중 어떤 부분을 더 깊게 들어가 볼까요?</p>
<p>===</p>
<p>Argo Workflows를 관제탑으로 삼고, 그 내부의 실무를 LangChain이 수행하는 <strong>&#39;지능형 문서 처리 파이프라인&#39;</strong>의 구체적인 설계도와 코드를 제시해 드립니다.</p>
<p>이 설계는 플랫폼 엔지니어링 리드님의 환경(K8s, MinIO, Airgapped, Bitbucket)에 최적화되어 있습니다.</p>
<hr>
<h3 id="1-argo-workflows-전체-파이프라인-설계-argo-yaml">1. Argo Workflows 전체 파이프라인 설계 (Argo YAML)</h3>
<p>Argo는 전체 과정을 <strong>DAG(Directed Acyclic Graph)</strong> 형태로 관리합니다. 각 단계는 독립된 컨테이너 파드(Pod)에서 실행됩니다.</p>
<pre><code class="language-yaml">apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: doc-ai-pipeline-
spec:
  entrypoint: main
  # 망분리 환경이므로 필요한 모든 이미지는 내부 레지스트리에 있어야 함
  templates:
  - name: main
    dag:
      tasks:
      - name: extract-raw
        template: untar-minio
      - name: process-and-classify
        dependencies: [extract-raw]
        template: langchain-processor
      - name: git-sync
        dependencies: [process-and-classify]
        template: git-pusher
      - name: vector-indexing
        dependencies: [git-sync]
        template: vector-indexer

  # 1단계: MinIO에 반입된 압축파일 해제
  - name: untar-minio
    container:
      image: internal-reg.com/ops-tools:v1
      command: [sh, -c]
      args: [&quot;mc cp myminio/raw/export.tar.gz /tmp &amp;&amp; tar -xzvf /tmp/export.tar.gz -C /data&quot;]
      volumeMounts:
      - name: workdir
        mountPath: /data

  # 2단계: LangChain 핵심 로직 (아래 Python 코드 실행)
  - name: langchain-processor
    container:
      image: internal-reg.com/langchain-processor:v1
      env:
      - name: LLM_ENDPOINT
        value: &quot;http://vllm-service.ai-namespace:8000/v1&quot;
      volumeMounts:
      - name: workdir
        mountPath: /data</code></pre>
<hr>
<h3 id="2-내부-langchain-구체적-로직-processorpy">2. 내부 LangChain 구체적 로직 (<code>processor.py</code>)</h3>
<p>이 코드는 <code>langchain-processor</code> 파드 내부에서 실행되며, 문서를 읽고 링크를 수정하며 분류까지 마칩니다.</p>
<pre><code class="language-python">import os
import re
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI # vLLM이 OpenAI 호환 API 제공
from langchain.output_parsers import ResponseSchema, StructuredOutputParser

# 1. 로컬 LLM 설정 (vLLM 또는 Ollama)
llm = ChatOpenAI(
    model=&quot;llama-3-70b-instruct&quot;, 
    base_url=os.getenv(&quot;LLM_ENDPOINT&quot;),
    api_key=&quot;none&quot; # 폐쇄망 내부용
)

# 2. 내부 링크 리졸버 (Internal Link Resolver)
def resolve_internal_links(content, current_file):
    # [[WikiLink]] 형식을 [WikiLink](./path/to/WikiLink.md)로 변환하는 정규표현식
    # 실제로는 파일 존재 여부를 체크하여 경로를 계산하는 로직 추가 가능
    pattern = r&#39;\[\[(.*?)\]\]&#39;
    def replace_link(match):
        link_text = match.group(1)
        # 예시: 모든 링크를 상대경로 .md로 통일
        return f&quot;[{link_text}](./{link_text.replace(&#39; &#39;, &#39;-&#39;)}.md)&quot;

    return re.sub(pattern, replace_link, content)

# 3. 문서 분류용 스키마 정의
response_schemas = [
    ResponseSchema(name=&quot;category&quot;, description=&quot;SOP, Library, Architecture 중 하나&quot;),
    ResponseSchema(name=&quot;tech_stack&quot;, description=&quot;K8s, Cilium, MinIO 등 관련 기술 리스트&quot;)
]
output_parser = StructuredOutputParser.from_response_schemas(response_schemas)

# 4. 프로세싱 메인 로직
def process_documents(input_dir, output_dir):
    prompt = ChatPromptTemplate.from_template(
        &quot;다음 문서의 내용을 분석하여 카테고리와 기술 스택을 분류하세요.\n{format_instructions}\n문서 내용: {context}&quot;
    )

    for root, _, files in os.walk(input_dir):
        for file in files:
            if file.endswith(&quot;.md&quot;):
                # A. 로드
                loader = UnstructuredMarkdownLoader(os.path.join(root, file))
                doc = loader.load()[0]

                # B. 내부 링크 수정
                refined_content = resolve_internal_links(doc.page_content, file)

                # C. LLM 기반 지능형 분류
                format_instructions = output_parser.get_format_instructions()
                chain = prompt | llm | output_parser
                result = chain.invoke({
                    &quot;context&quot;: refined_content[:1500], 
                    &quot;format_instructions&quot;: format_instructions
                })

                # D. 결과 저장 (wiki 폴더 구조화)
                target_path = os.path.join(output_dir, result[&#39;category&#39;], file)
                os.makedirs(os.path.dirname(target_path), exist_ok=True)

                with open(target_path, &quot;w&quot;) as f:
                    # YAML 메타데이터 삽입
                    f.write(f&quot;---\ntech: {result[&#39;tech_stack&#39;]}\n---\n\n&quot;)
                    f.write(refined_content)

if __name__ == &quot;__main__&quot;:
    process_documents(&quot;/data/raw&quot;, &quot;/data/wiki&quot;)</code></pre>
<hr>
<h3 id="3-설계의-핵심-포인트">3. 설계의 핵심 포인트</h3>
<ol>
<li><p><strong>데이터 무결성 유지 (Link Resolver):</strong></p>
<ul>
<li>스크립트 내 <code>resolve_internal_links</code> 함수를 통해 Obsidian에서 쓰던 <code>[[링크]]</code>가 일반 Markdown 뷰어나 Git 환경에서도 깨지지 않도록 강제로 변환합니다. 이는 지식 베이스의 이동성을 확보하는 핵심입니다.</li>
</ul>
</li>
<li><p><strong>구조화된 출력 (Structured Output):</strong></p>
<ul>
<li>LangChain의 <code>StructuredOutputParser</code>를 사용하여 LLM이 자유분방하게 답변하지 않고, 우리가 정한 JSON 형식(Category, Tech Stack)에 맞춰 엄격하게 응답하도록 강제했습니다. 덕분에 자동화된 폴더 배치가 가능해집니다.</li>
</ul>
</li>
<li><p><strong>Argo를 통한 자원 제어:</strong></p>
<ul>
<li>LLM 처리가 필요한 <code>langchain-processor</code> 단계에만 GPU 노드 셀렉터(<code>nodeSelector</code>)를 설정하여, 값비싼 GPU 자원을 딱 필요한 순간에만 점유하고 반납하게 설계할 수 있습니다.</li>
</ul>
</li>
<li><p><strong>망분리 환경의 확장성:</strong></p>
<ul>
<li>6개월 뒤 GitLab으로 전환 시, Argo Workflow의 <code>git-pusher</code> 템플릿 내의 URL과 인증 정보만 수정하면 됩니다. 로직(Python)은 건드릴 필요가 없습니다.</li>
</ul>
</li>
</ol>
<p>이제 이 코드를 바탕으로 첫 번째 파이프라인을 구동해 보시면, <strong>&quot;MinIO에 압축파일만 던지면 AI가 알아서 링크 고치고 분류해서 Git에 예쁘게 넣어주는&quot;</strong> 플랫폼 리드님만의 자동화 시스템이 작동하게 됩니다. </p>
<p>가장 먼저 테스트해보고 싶은 문서 샘플(예: Cilium BGP 설정 가이드 등)이 있으신가요? 그에 맞춰 분류 프롬프트를 더 정교하게 다듬어 드릴 수 있습니다.</p>
<p>===</p>
<p>지적하신 대로 <strong>Metadata YAML</strong>은 LLM Wiki와 RAG(검색 증강 생성) 시스템의 &#39;골격&#39;이자 &#39;내비게이션&#39; 역할을 합니다. 단순히 텍스트만 저장하는 것이 아니라, 문서의 성격과 맥락을 정형화된 데이터로 추출해 두어야 나중에 AI 에이전트가 수천 개의 문서 중 가장 정확한 조각을 찾아낼 수 있습니다.</p>
<p>플랫폼 엔지니어링 지식 베이스에 최적화된 <strong>Metadata YAML 설계 전략</strong>과 이를 자동 생성하는 <strong>LangChain 구현 방안</strong>을 정리해 드립니다.</p>
<hr>
<h3 id="1-왜-metadata-yaml이-검색의-질을-결정하는가">1. 왜 Metadata YAML이 검색의 질을 결정하는가?</h3>
<p>벡터 검색(의미 기반 검색)은 문맥 파악에는 뛰어나지만, 특정 조건에서 구멍이 생깁니다. 이때 YAML 데이터가 이를 메워줍니다.</p>
<ul>
<li><strong>정밀한 필터링 (Metadata Filtering):</strong> &quot;Cilium 관련 문서 중 <strong>SOP(표준 운영 절차)</strong>만 찾아줘&quot;라고 할 때, 벡터 DB는 모든 Cilium 문서를 다 가져오려 하지만, YAML 필터가 있으면 즉시 SOP 카테고리만 추려낼 수 있습니다.</li>
<li><strong>최신성 보장 (Recency):</strong> &quot;가장 최근에 업데이트된 <strong>MinIO 설치 가이드</strong>&quot;를 찾을 때 <code>last_verified</code> 필드가 있으면 AI가 구버전 가이드를 보고 오답을 내는 것을 방지합니다.</li>
<li><strong>관계 추적 (Graph-like Search):</strong> <code>related_components</code> 필드를 통해 특정 장애가 발생했을 때 연관된 다른 인프라 문서를 함께 참조할 수 있게 합니다.</li>
</ul>
<hr>
<h3 id="2-인프라-지식-베이스를-위한-권장-yaml-스키마">2. 인프라 지식 베이스를 위한 권장 YAML 스키마</h3>
<p>단순히 제목과 날짜만 넣는 것이 아니라, 엔지니어링 관점에서 유효한 필드들을 정의해야 합니다.</p>
<pre><code class="language-yaml">---
# 문서 식별 및 분류
id: &quot;SOP-K8S-001&quot;
title: &quot;K8s 노드 확장 및 드레인 절차&quot;
category: &quot;SOP&quot;              # Architecture, Library, Troubleshooting, SOP
tech_stack: [&quot;K8s&quot;, &quot;Cilium&quot;] # 관련 핵심 기술

# 운영 맥락 (AIOps 핵심 데이터)
severity: &quot;High&quot;             # 장애 대응 시 중요도
target_audience: &quot;DevOps&quot;     # 문서를 읽을 대상
environment: &quot;Production&quot;    # 적용 환경

# 관리 및 신뢰도
last_verified: &quot;2026-04-28&quot;  # 마지막 검증 일자 (버전 관리)
source: &quot;Confluence&quot;         # 원본 출처
verified_by: &quot;Lead Engineer&quot; # 검토자

# 연결성 (Graph View용)
related_docs: [&quot;ARCH-LAKE-02&quot;, &quot;SOP-MINIO-05&quot;]
---</code></pre>
<hr>
<h3 id="3-langchain을-활용한-자동-추출-로직-structured-output">3. LangChain을 활용한 자동 추출 로직 (Structured Output)</h3>
<p>LLM이 문서를 읽고 위와 같은 YAML을 일관성 있게 뽑아내도록 만드는 구체적인 코드 예시입니다.</p>
<pre><code class="language-python">from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
from typing import List

# 1. 추출하고 싶은 YAML 구조 정의 (Pydantic 클래스)
class DocMetadata(BaseModel):
    category: str = Field(description=&quot;문서의 카테고리 (SOP, Library, Architecture, OperationLog)&quot;)
    tech_stack: List[str] = Field(description=&quot;문서에 언급된 주요 기술 스택 리스트&quot;)
    summary: str = Field(description=&quot;문서 내용을 한 문장으로 요약&quot;)
    severity: str = Field(description=&quot;운영상의 중요도 (Critical, High, Medium, Low)&quot;)

# 2. LLM 및 추출 체인 설정
llm = ChatOpenAI(model=&quot;gpt-4-turbo&quot;, temperature=0) # 또는 폐쇄망용 로컬 모델
structured_llm = llm.with_structured_output(DocMetadata)

# 3. 프롬프트 구성
prompt = ChatPromptTemplate.from_template(&quot;&quot;&quot;
당신은 플랫폼 엔지니어링 기술 문서 관리자입니다. 
다음 문서의 내용을 분석하여 정해진 구조에 따라 메타데이터를 추출하세요.

문서 내용:
{context}
&quot;&quot;&quot;)

# 4. 실행 및 결과 확인
chain = prompt | structured_llm
doc_content = &quot;Cilium BGP Control Plane 설정 시... (문서 본문 생략)&quot;
metadata = chain.invoke({&quot;context&quot;: doc_content})

print(metadata.category)     # &#39;Library&#39;
print(metadata.tech_stack)   # [&#39;Cilium&#39;, &#39;Networking&#39;, &#39;BGP&#39;]</code></pre>
<hr>
<h3 id="4-aiops로-가는-검색-효율-최적화-전략">4. AIOps로 가는 검색 효율 최적화 전략</h3>
<p>이렇게 생성된 YAML 데이터는 벡터 DB에 <strong>&#39;Metadata&#39;</strong> 필드로 함께 저장되어 다음과 같은 방식으로 검색 효율을 높입니다.</p>
<ol>
<li><strong>사전 필터링 (Pre-filtering):</strong> 사용자의 질문에서 &#39;SOP&#39;라는 의도를 파악하면, 벡터 검색을 수행하기 전에 DB에서 <code>category == &#39;SOP&#39;</code>인 데이터만 대상으로 검색 범위를 좁힙니다. 검색 속도와 정확도가 비약적으로 상승합니다.</li>
<li><strong>가중치 부여 (Re-ranking):</strong> 검색 결과 중 <code>last_verified</code>가 최신이거나 <code>severity</code>가 높은 문서에 가중치를 주어 상단에 배치합니다.</li>
<li><strong>지식 그래프 연결:</strong> Obsidian에서 <code>[[문서명]]</code>으로 연결된 구조를 YAML의 <code>related_docs</code>와 매칭하여, AI가 &quot;이 장애는 A 문서에 나오지만, 연관된 B 문서의 설정도 확인해야 합니다&quot;라고 답변하게 만듭니다.</li>
</ol>
<h3 id="결론-1">결론</h3>
<p>Metadata YAML을 만드는 과정은 단순히 파일을 정리하는 것이 아니라, <strong>&#39;데이터를 지식으로 구조화&#39;</strong>하는 과정입니다. </p>
<p>Argo Workflows 파이프라인의 <code>langchain-processor</code> 단계에서 이 추출 로직을 강하게 적용할수록, 향후 구축될 AIOps 에이전트의 답변 품질은 1,000노드 클러스터의 복잡성을 견딜 수 있을 만큼 견고해질 것입니다.</p>
<p>이 추출 과정에서 특정 기술 스택(예: Cilium 특화 필드 등)에 대해 더 세밀한 분류 기준이 필요하시면 말씀해 주세요. 그에 맞는 정교한 Pydantic 스키마를 설계해 드릴 수 있습니다.</p>
<p>===</p>
<p>Confluence에서 단순 텍스트가 아닌 <strong>이미지, 다이어그램(draw.io), 그리고 코드 블록</strong>을 추출하여 Git에 최적으로 저장하고 관리하는 방법은 지식 베이스의 &#39;가독성&#39;과 &#39;재사용성&#39;을 결정짓는 핵심 요소입니다.</p>
<p>플랫폼 엔지니어링 리드로서 대규모 인프라 구성을 시각화하고 코드를 관리하기 위한 구체적인 가이드를 정리해 드립니다.</p>
<hr>
<h3 id="1-사진-및-이미지-관리-assets-폴더-전략">1. 사진 및 이미지 관리 (<code>assets</code> 폴더 전략)</h3>
<p>Confluence 페이지에 첨부된 사진들은 추출 시 별도의 파일로 내려받아집니다. 이를 Git에서 깨지지 않게 관리하려면 <strong>상대 경로(Relative Path)</strong>를 유지하는 것이 가장 중요합니다.</p>
<ul>
<li><strong>저장 구조:</strong> Git 저장소 내에 <code>assets/</code> 또는 <code>images/</code>라는 공통 폴더를 만들거나, 각 문서 폴더 하위에 <code>_assets/</code> 폴더를 두어 관리합니다.<ul>
<li>예: <code>wiki/Architecture/Lakehouse/assets/diagram-v1.png</code></li>
</ul>
</li>
<li><strong>Markdown 링크 방식:</strong> <code>![설명](../assets/image.png)</code> 와 같이 상대 경로로 작성해야 Obsidian, Git(GitHub/GitLab), 그리고 나중에 구축할 Quartz 위키에서도 이미지가 정상적으로 보입니다.</li>
<li><strong>자동화 시:</strong> <code>confluence-md-exporter</code> 사용 시 <code>--relative-links</code> 옵션을 활성화하면 Confluence의 첨부 파일 링크를 마크다운 상대 경로로 자동 변환해 줍니다.</li>
</ul>
<hr>
<h3 id="2-구성도-drawio--lucidchart-처리">2. 구성도 (draw.io / Lucidchart) 처리</h3>
<p>가장 까다로운 부분입니다. Confluence 내부의 draw.io는 데이터가 페이지 XML에 내장되어 있어 단순히 이미지로만 저장하면 나중에 수정이 불가능합니다.</p>
<ul>
<li><strong>추천 방식: <code>.drawio.svg</code> 또는 <code>.drawio.png</code> 활용</strong><ul>
<li><strong>편집성 유지:</strong> draw.io 파일을 저장할 때 <strong>&#39;Editable SVG/PNG&#39;</strong> 형태로 내보내면, 파일 자체는 이미지처럼 보이지만 draw.io 편집기로 열면 레이어와 객체가 살아있어 수정이 가능합니다.</li>
</ul>
</li>
<li><strong>Obsidian 연동:</strong> <strong>&#39;Diagrams.net (draw.io) Integration&#39;</strong> 플러그인을 사용하세요.<ul>
<li>Git에 저장된 <code>.drawio.svg</code> 파일을 Obsidian에서 바로 클릭하여 수정하고 저장할 수 있습니다. 별도의 앱을 띄울 필요가 없어 매우 효율적입니다.</li>
</ul>
</li>
<li><strong>자동화 시나리오:</strong><ol>
<li>추출 스크립트에서 Confluence API를 통해 draw.io 매크로의 원본 XML 데이터를 추출합니다.</li>
<li>이를 <code>.drawio</code> 파일로 저장하거나, 이미지를 미리 렌더링하여 <code>assets/</code> 폴더에 넣습니다.</li>
</ol>
</li>
</ul>
<hr>
<h3 id="3-문서-내-코드-블록-code-block-수집">3. 문서 내 코드 블록 (Code Block) 수집</h3>
<p>Confluence의 코드 블록 매크로는 다행히 마크다운의 <strong>Fenced Code Blocks</strong> ( ``` )로 비교적 잘 변환됩니다.</p>
<ul>
<li><strong>언어 감지 (Syntax Highlighting):</strong><ul>
<li>Confluence 코드 블록에서 지정한 언어(예: python, yaml, bash)가 마크다운 코드 블록 상단에 그대로 붙습니다 (예: ```yaml).</li>
<li>이를 통해 Git(GitLab/Bitbucket)이나 Obsidian에서 문법 강조가 정상적으로 작동합니다.</li>
</ul>
</li>
<li><strong>코드 추출의 정교화:</strong><ul>
<li>일부 추출 도구는 코드 블록 내부의 특수 문자( <code>&lt;</code>, <code>&gt;</code> 등)를 잘못 치환하는 경우가 있습니다. 파이썬의 <code>BeautifulSoup</code>을 이용해 가공할 때 <code>code</code> 태그 내의 텍스트는 원형 그대로(Preformatted) 가져오도록 예외 처리가 필요합니다.</li>
</ul>
</li>
<li><strong>코드의 지식화 (Metadata 활용):</strong><ul>
<li>지난번에 논의한 <strong>YAML Metadata</strong>에 <code>contains_code: true</code> 또는 <code>code_lang: [&quot;yaml&quot;, &quot;shell&quot;]</code> 같은 필드를 LLM이 자동으로 추가하게 하세요.</li>
<li>나중에 AI 에이전트에게 &quot;Cilium BGP 설정 샘플 코드만 찾아줘&quot;라고 할 때 검색 정확도가 비약적으로 향상됩니다.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="4-플랫폼-리드를-위한-운영-제언-진실의-원천-결정">4. 플랫폼 리드를 위한 운영 제언: &#39;진실의 원천&#39; 결정</h3>
<p>이 과정에서 가장 중요한 결정은 <strong>&quot;어디에서 그림을 수정할 것인가?&quot;</strong>입니다.</p>
<ol>
<li><strong>Confluence에서 계속 수정:</strong> Git은 단순 백업/검색용이 됩니다. (추천하지 않음)</li>
<li><strong>Obsidian(Git)에서 수정:</strong> 이제부터 구성도 수정은 Git에 저장된 <code>.drawio.svg</code>를 통해서만 합니다. (권장)<ul>
<li>이 경우, 팀원들에게 &quot;이제 구성도는 Confluence가 아니라 Git 저장소에서 관리한다&quot;는 <strong>Documentation as Code (DaC)</strong> 원칙을 공표해야 합니다.</li>
</ul>
</li>
</ol>
<h3 id="실천-단계">[실천 단계]</h3>
<ul>
<li><strong>1단계:</strong> <code>confluence-md-exporter</code>를 실행하여 이미지 파일들이 상대 경로로 잘 추출되는지 확인합니다.</li>
<li><strong>2단계:</strong> draw.io 파일 중 중요한 것 하나를 <code>.drawio.svg</code>로 변환해 Git에 넣고, Obsidian 플러그인으로 편집이 잘 되는지 테스트합니다.</li>
<li><strong>3단계:</strong> 추출된 마크다운 내의 코드 블록이 깨지지 않았는지, <code>yaml</code>이나 <code>bash</code> 태그가 잘 붙어있는지 점검합니다.</li>
</ul>
<p>이미지와 구성도까지 Git에 완벽히 통합되면, 1,000노드 클러스터의 복잡한 아키텍처를 AI가 시각적 맥락까지 포함하여 이해할 수 있는 강력한 토대가 마련됩니다. 이 과정에서 특정 draw.io 매크로가 깨지는 등의 기술적 이슈가 발생하면 해결 방법을 더 상세히 찾아봐 드릴 수 있습니다.</p>
<p>===</p>
<p>1,000노드 규모의 복잡한 인프라를 운영하다 보면 문서의 &#39;파편화&#39;와 &#39;노후화&#39;는 피할 수 없는 숙제입니다. 특히 AI가 과거의 잘못된 설정(예: 이전 버전의 Cilium BGP 설정)을 정답인 것처럼 제안하면 운영상 큰 리스크가 될 수 있습니다.</p>
<p>Git 저장소에 쌓이는 &#39;쓰레기 데이터&#39;를 정리하고, AI가 가장 <strong>최신의, 유효한 지식</strong>만 참조하게 만드는 5단계 관리 전략을 제안합니다.</p>
<hr>
<h2 id="1-문서-상태status-메타데이터-강제화">1. 문서 상태(Status) 메타데이터 강제화</h2>
<p>가장 먼저 할 일은 모든 문서의 <strong>생애주기(Lifecycle)</strong>를 YAML 메타데이터로 정의하는 것입니다. 단순히 저장하는 것이 아니라, LLM이 이 상태를 읽고 필터링하게 만듭니다.</p>
<ul>
<li><strong>필수 필드 정의:</strong><ul>
<li><code>status</code>: <code>active</code>(현재 사용), <code>deprecated</code>(곧 폐기), <code>archive</code>(참조용 구버전)</li>
<li><code>last_verified_at</code>: 마지막으로 기술 검증이 된 날짜</li>
<li><code>applies_to_version</code>: 해당 문서가 유효한 솔루션 버전 (예: Cilium 1.15)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-active와-archive-저장소-분리-directory-strategy">2. &#39;Active&#39;와 &#39;Archive&#39; 저장소 분리 (Directory Strategy)</h2>
<p>Git의 장점은 삭제해도 기록이 남는다는 것입니다. 따라서 <strong>현재 시스템과 맞지 않는 문서는 과감히 메인 경로에서 치워야 합니다.</strong></p>
<ul>
<li><strong>구조 예시:</strong><ul>
<li><code>/wiki/active/</code>: 현재 1,000노드 클러스터에 적용 중인 실시간 SOP 및 가이드</li>
<li><code>/wiki/archive/</code>: 과거 기록, 이전 아키텍처 (검색 대상에서 제외하거나 낮은 가중치 부여)</li>
</ul>
</li>
<li><strong>운영 방식:</strong> 새로운 버전의 문서가 Git에 들어오면, LLM 분류기가 기존 문서의 제목이나 내용을 비교하여 <strong>&quot;이 문서는 구버전(Archive)으로 이동시키겠습니까?&quot;</strong>라고 제안하거나 자동으로 이동시킵니다.</li>
</ul>
<hr>
<h2 id="3-중복-및-충돌-감지-파이프라인-semantic-deduplication">3. 중복 및 충돌 감지 파이프라인 (Semantic Deduplication)</h2>
<p>Confluence에서 새로운 문서가 수집될 때, 기존 Git에 있는 문서들과 <strong>&#39;의미적 유사도&#39;</strong>를 비교하는 단계를 추가합니다.</p>
<ul>
<li><strong>작동 원리 (LangChain 활용):</strong><ol>
<li>새 문서가 들어오면 기존 벡터 DB에서 가장 유사한 문서 3개를 먼저 찾습니다.</li>
<li>LLM에게 질문합니다: &quot;새 문서와 기존 문서의 내용이 80% 이상 겹치나요? 아니면 기존 내용을 업데이트하는 것인가요?&quot;</li>
<li><strong>충돌 발생 시:</strong> LLM이 두 문서를 병합(Merge)하거나, 기존 문서를 <code>status: archive</code>로 변경하도록 자동 PR(Pull Request)을 생성합니다.</li>
</ol>
</li>
</ul>
<hr>
<h2 id="4-벡터-db의-검색-가중치-조정-recency-boosting">4. 벡터 DB의 검색 가중치 조정 (Recency Boosting)</h2>
<p>검색 시 단순히 &#39;유사도&#39;만 보지 않고, <strong>&#39;최신성&#39;</strong>에 점수를 더 주는 방식을 도입합니다.</p>
<ul>
<li><strong>Metadata Filtering:</strong> RAG 엔진이 검색을 수행할 때 <code>status: active</code>인 문서만 검색하도록 필터를 겁니다.</li>
<li><strong>Time Decay Scaling:</strong> 검색 결과 점수(Score)에 <code>last_verified_at</code> 날짜를 계산식에 넣어, 최근 문서가 상단에 배치되게 합니다.<blockquote>
<p><strong>Score = Semantic Similarity + (Recency weight * Time Factor)</strong></p>
</blockquote>
</li>
</ul>
<hr>
<h2 id="5-정기적-지식-점검-자동화-governance">5. 정기적 &#39;지식 점검&#39; 자동화 (Governance)</h2>
<p>문서가 낡는 것을 방지하기 위해 <strong>n8n과 Argo Workflows</strong>를 활용한 알림 시스템을 구축합니다.</p>
<ol>
<li><strong>검증 기간 설정:</strong> <code>last_verified_at</code>이 6개월이 지난 문서를 추출합니다.</li>
<li><strong>담당자 알림 (n8n):</strong> 플랫폼 팀 Slack에 &quot;이 SOP는 180일 동안 업데이트되지 않았습니다. 여전히 유효한가요?&quot;라고 알림을 보냅니다.</li>
<li><strong>자동 폐기:</strong> 만약 검증되지 않고 1년이 지나면 자동으로 <code>archive</code> 폴더로 이동시키고 벡터 DB 인덱스를 삭제합니다.</li>
</ol>
<hr>
<h3 id="결론-플랫폼-리드를-위한-운영-조언">결론: 플랫폼 리드를 위한 운영 조언</h3>
<p>1,000노드 클러스터 운영에서 AI의 답변 정확도를 높이려면 <strong>&quot;AI에게 무엇을 가르치지 않을 것인가&quot;</strong>가 &quot;무엇을 가르칠 것인가&quot;만큼 중요합니다.</p>
<ul>
<li><strong>추천 실천:</strong> 지금 바로 Git 저장소 최상위에 <code>archive/</code> 폴더를 만드시고, LLM 위키 파이프라인에 <strong>&quot;중복 문서 발견 시 기존 문서 아카이브 로직&quot;</strong>을 한 줄 추가해 보세요.</li>
</ul>
<p>이렇게 관리하면 시스템 상태와 맞지 않는 &#39;죽은 지식&#39;이 AI의 답변을 오염시키는 일을 원천 차단할 수 있습니다. 이 과정 중 특정 단계(예: 중복 감지 로직)의 구체적인 코드가 필요하신가요?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A27d]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A27d</link>
            <guid>https://velog.io/@youngkyoo_kim/26A27d</guid>
            <pubDate>Mon, 27 Apr 2026 08:29:34 GMT</pubDate>
            <description><![CDATA[<p>MinIO AIStor Tables는 Apache Iceberg 테이블 포맷을 기반으로 하며, 객체 스토리지의 유연성과 관계형 데이터베이스의 강력한 쿼리 성능을 결합한 구조입니다. 제공해주신 문서를 바탕으로 <strong>Inventory API로 수집된 Parquet 파일을 Iceberg 기반 AIStor Table로 변환하고, 이를 Polaris 및 다양한 엔진(Trino, Spark)과 연계하여 활용하는 아키텍처</strong>를 설계해 드립니다.</p>
<hr>
<h3 id="1-aistor-table-생성-및-전환-프로세스-pipeline-design">1. AIStor Table 생성 및 전환 프로세스 (Pipeline Design)</h3>
<p>Inventory API를 통해 수집된 Parquet 파일은 단순한 &#39;객체&#39; 상태입니다. 이를 고성능 분석이 가능한 &#39;AIStor Table&#39;로 승격시키는 과정입니다.</p>
<h4 id="step-1-landing-zone-저장-inventory-api">Step 1: Landing Zone 저장 (Inventory API)</h4>
<ul>
<li><strong>작업:</strong> Inventory API가 소스 시스템에서 데이터를 수집하여 MinIO의 <code>landing-bucket</code>에 Parquet 포맷으로 저장합니다.</li>
<li><strong>특징:</strong> 이 단계에서는 아직 Iceberg 메타데이터가 없는 순수 데이터 파일 상태입니다.</li>
</ul>
<h4 id="step-2-iceberg-table-schema-정의-및-생성">Step 2: Iceberg Table Schema 정의 및 생성</h4>
<ul>
<li><strong>작업:</strong> Spark 또는 Trino를 사용하여 AIStor 내에 Iceberg 테이블 껍데기(Schema)를 생성합니다.</li>
<li><strong>설정:</strong> Polaris Catalog를 사용하므로, 생성 시 <code>catalog</code> 타입을 <code>polaris</code>로 지정합니다.</li>
</ul>
<h4 id="step-3-데이터-로드-및-변환-ingestion-to-iceberg">Step 3: 데이터 로드 및 변환 (Ingestion to Iceberg)</h4>
<p>단순 복사가 아닌 Iceberg의 메타데이터(Manifest, Snapshot)를 생성하며 데이터를 옮깁니다.</p>
<ul>
<li><strong>방법:</strong> <code>INSERT INTO aistor_table SELECT * FROM parquet_raw_files</code></li>
<li><strong>최적화:</strong> 이 과정에서 <strong>Partitioning</strong>(날짜, 부서 등)과 <strong>Sorting</strong>(Z-order 등)을 적용하여 향후 쿼리 시 스캔 범위를 최소화합니다.</li>
</ul>
<hr>
<h3 id="2-하부-인프라-및-거버넌스-연계-설계">2. 하부 인프라 및 거버넌스 연계 설계</h3>
<p>앞서 논의된 <strong>Keycloak, Polaris, AIStor</strong> 환경을 녹여낸 통합 이용 방안입니다.</p>
<h4 id="①-권한-및-보안-keycloak--polaris">① 권한 및 보안 (Keycloak + Polaris)</h4>
<ul>
<li><strong>RBAC 적용:</strong> 사용자가 Trino나 Spark에서 AIStor Table에 접근할 때, <strong>Keycloak</strong>에서 발급받은 토큰이 <strong>Polaris</strong>로 전달됩니다.</li>
<li><strong>Access Control:</strong> Polaris는 Iceberg 테이블의 네임스페이스 및 테이블 단위로 권한을 체크하며, 승인된 경우에만 MinIO의 임시 자격 증명을 반환합니다.</li>
</ul>
<h4 id="②-엔진별-활용-trino-spark-starrocks">② 엔진별 활용 (Trino, Spark, StarRocks)</h4>
<ul>
<li><strong>Trino/StarRocks:</strong> 실시간 대시보드 및 고속 쿼리에 활용합니다. Iceberg의 <strong>Snapshot</strong> 기능을 이용해 특정 시점의 데이터로 쿼리(Time Travel)를 수행할 수 있습니다.</li>
<li><strong>Spark:</strong> 대규모 배치 처리 및 AI 모델 학습용 데이터 전처리에 활용합니다. Iceberg 테이블에서 필요한 증분 데이터(Incremental Read)만 효율적으로 읽어옵니다.</li>
</ul>
<h4 id="③-데이터-생애주기-관리-ilm--tiering-연계">③ 데이터 생애주기 관리 (ILM &amp; Tiering 연계)</h4>
<ul>
<li><strong>Hot Tier (NVMe):</strong> 최근 생성된 Iceberg 데이터와 메타데이터 파일들을 보관하여 3,000 RPS 수준의 고성능 I/O를 지원합니다.</li>
<li><strong>Cold Tier (SATA SSD - Pool 2):</strong> Iceberg의 <strong>Partition Evolution</strong> 기능을 활용합니다. 오래된 날짜의 데이터 파티션 파일들만 Cold Pool로 이동(Tiering)시켜 저장 효율을 극대화합니다.</li>
</ul>
<hr>
<h3 id="3-aistor-table-운영-및-최적화-방안-devops-가이드">3. AIStor Table 운영 및 최적화 방안 (DevOps 가이드)</h3>
<p>1,000노드 규모에서 AIStor Table의 성능을 유지하기 위한 핵심 운영 전략입니다.</p>
<table>
<thead>
<tr>
<th align="left">관리 항목</th>
<th align="left">최적화 방안 (Best Practice)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Compaction</strong></td>
<td align="left">수집 시 발생하는 작은 파티션/파일들을 주기적으로 병합하여 메타데이터 읽기 부하를 줄입니다. (Spark/Trino 활용)</td>
</tr>
<tr>
<td align="left"><strong>Snapshot 익스파이어</strong></td>
<td align="left">Iceberg는 버전 관리를 위해 Snapshot을 유지합니다. 너무 오래된 Snapshot은 삭제하여 스토리지 공간 낭비를 막습니다.</td>
</tr>
<tr>
<td align="left"><strong>Manifest 관리</strong></td>
<td align="left">쿼리 성능 향상을 위해 Manifest 파일의 크기와 개수를 모니터링하고 최적화합니다.</td>
</tr>
<tr>
<td align="left"><strong>Schema Evolution</strong></td>
<td align="left">비즈니스 로직 변경 시 데이터 재작성 없이 컬럼을 추가/수정하여 유연성을 확보합니다.</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-기대-효과-및-활용-시나리오">4. 기대 효과 및 활용 시나리오</h3>
<ol>
<li><strong>초고속 데이터 탐색:</strong> 단순 객체 조회가 아닌 SQL 기반의 고성능 분석이 가능해지며, Iceberg의 메타데이터 필터링 덕분에 스토리지 부하가 획기적으로 줄어듭니다.</li>
<li><strong>데이터 일관성 (ACID):</strong> 여러 엔진(Spark, Trino 등)이 동시에 데이터를 쓰고 읽어도 트랜잭션 보장 덕분에 데이터 오염이 발생하지 않습니다.</li>
<li><strong>Vibe Coding Zone 연계:</strong> 개발 환경에서 운영 환경의 특정 시점 Snapshot을 Read-only로 마운트하여 실제 데이터를 기반으로 안전하게 모델링 실험을 수행할 수 있습니다.</li>
</ol>
<h3 id="설계-제언">설계 제언</h3>
<p>&quot;Inventory API로 들어오는 원천 Parquet은 <strong>&#39;재료&#39;</strong>이고, AIStor Table은 이를 맛있게 요리한 <strong>&#39;완성된 요리&#39;</strong>입니다. 8월에 도입될 130대의 대규모 Cold Pool을 고려할 때, <strong>Partitioning 전략</strong>을 지금부터 세밀하게 설계해 두어야 나중에 대규모 데이터 이동 시 시스템 전체의 Latency 튀는 현상을 막을 수 있습니다.&quot;</p>
<p>이 과정에서 특정 쿼리 엔진(예: Trino)의 성능을 우선시하시나요? 그렇다면 그에 맞춘 구체적인 Iceberg Partitioning 튜닝 파라미터를 추가로 제안해 드릴 수 있습니다.</p>
<p>===</p>
<p>하부 페이지도 추가로 확인할게.이제 설계를 시작할게. AIStor Tables docs와 Inventory API 내용을 종합해서 전체 파이프라인을 설계할게.</p>
<hr>
<h1 id="inventory-parquet-→-aistor-iceberg-table-파이프라인-설계">Inventory Parquet → AIStor Iceberg Table 파이프라인 설계</h1>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                        Hot/Cold MinIO AIStor                     │
│                                                                   │
│  ① mc inventory generate                                         │
│     └── inventory-bucket/YYYY-MM-DD/*.parquet  (원시 데이터)      │
│                                                                   │
│  ② PyIceberg / Spark (ETL)                                       │
│     └── Parquet 읽기 → 스키마 정규화 → AIStor Tables에 쓰기       │
│                                                                   │
│  ③ AIStor Tables (Iceberg REST Catalog)                          │
│     warehouse: infra-analytics                                    │
│     └── namespace: inventory                                      │
│         ├── table: object_inventory     ← 증분 append              │
│         ├── table: tier_summary         ← daily rollup            │
│         └── table: ilm_candidates      ← ILM 대상 선별           │
│                                                                   │
│  ④ 쿼리 클라이언트                                                │
│     Trino / Spark / Dremio / PyIceberg                           │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="step-1--inventory-수집-설정-mc-inventory">Step 1 — Inventory 수집 설정 (mc inventory)</h2>
<pre><code class="language-bash"># Inventory 설정 생성 (Hot 클러스터 전체 대상)
mc inventory generate HOT \
  --destination HOT/inventory-bucket \
  --schedule &quot;daily&quot;

# 상태 확인
mc inventory list HOT
mc inventory status HOT/&lt;inventory-id&gt;

# 수동 실행 (즉시 수집)
mc inventory resume HOT/&lt;inventory-id&gt;</code></pre>
<p>생성된 Parquet 파일 구조:</p>
<pre><code>inventory-bucket/
  └── YYYY-MM-DD/
      └── &lt;bucket-name&gt;/
          ├── part-00000.parquet   ← 오브젝트 메타 포함
          └── part-00001.parquet</code></pre><p>Parquet 컬럼 주요 필드 (Inventory API 기준):</p>
<pre><code>bucket, key, version_id, is_latest, is_delete_marker,
size, last_modified, e_tag, storage_class,
replication_status, encryption_status,
object_lock_mode, object_lock_retain_until_date</code></pre><hr>
<h2 id="step-2--aistor-tables-리소스-구성">Step 2 — AIStor Tables 리소스 구성</h2>
<h3 id="2-1-warehouse--namespace--table-생성">2-1. Warehouse / Namespace / Table 생성</h3>
<pre><code class="language-bash"># Warehouse 생성 (analytics용)
mc table warehouse create HOT infra-analytics

# Namespace 생성
mc table namespace create HOT infra-analytics inventory

# 메인 테이블: object_inventory (전체 오브젝트 스냅샷)
mc table create HOT infra-analytics inventory object_inventory \
  --schema &#39;{
    &quot;type&quot;: &quot;struct&quot;,
    &quot;fields&quot;: [
      {&quot;id&quot;:1,  &quot;name&quot;:&quot;snapshot_date&quot;,       &quot;type&quot;:&quot;date&quot;,    &quot;required&quot;:true},
      {&quot;id&quot;:2,  &quot;name&quot;:&quot;cluster&quot;,             &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:true},
      {&quot;id&quot;:3,  &quot;name&quot;:&quot;bucket&quot;,              &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:true},
      {&quot;id&quot;:4,  &quot;name&quot;:&quot;key&quot;,                 &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:true},
      {&quot;id&quot;:5,  &quot;name&quot;:&quot;size_bytes&quot;,          &quot;type&quot;:&quot;long&quot;,    &quot;required&quot;:false},
      {&quot;id&quot;:6,  &quot;name&quot;:&quot;last_modified&quot;,       &quot;type&quot;:&quot;timestamptz&quot;,&quot;required&quot;:false},
      {&quot;id&quot;:7,  &quot;name&quot;:&quot;storage_class&quot;,       &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:false},
      {&quot;id&quot;:8,  &quot;name&quot;:&quot;is_latest&quot;,           &quot;type&quot;:&quot;boolean&quot;, &quot;required&quot;:false},
      {&quot;id&quot;:9,  &quot;name&quot;:&quot;is_delete_marker&quot;,    &quot;type&quot;:&quot;boolean&quot;, &quot;required&quot;:false},
      {&quot;id&quot;:10, &quot;name&quot;:&quot;replication_status&quot;,  &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:false},
      {&quot;id&quot;:11, &quot;name&quot;:&quot;version_id&quot;,          &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:false},
      {&quot;id&quot;:12, &quot;name&quot;:&quot;tier&quot;,                &quot;type&quot;:&quot;string&quot;,  &quot;required&quot;:false}
    ]
  }&#39;

# ILM 후보 테이블 (전환 대상 분석용)
mc table create HOT infra-analytics inventory ilm_candidates \
  --schema &#39;{
    &quot;type&quot;: &quot;struct&quot;,
    &quot;fields&quot;: [
      {&quot;id&quot;:1, &quot;name&quot;:&quot;snapshot_date&quot;,   &quot;type&quot;:&quot;date&quot;,   &quot;required&quot;:true},
      {&quot;id&quot;:2, &quot;name&quot;:&quot;bucket&quot;,          &quot;type&quot;:&quot;string&quot;, &quot;required&quot;:true},
      {&quot;id&quot;:3, &quot;name&quot;:&quot;prefix&quot;,          &quot;type&quot;:&quot;string&quot;, &quot;required&quot;:false},
      {&quot;id&quot;:4, &quot;name&quot;:&quot;object_count&quot;,    &quot;type&quot;:&quot;long&quot;,   &quot;required&quot;:false},
      {&quot;id&quot;:5, &quot;name&quot;:&quot;total_size_bytes&quot;,&quot;type&quot;:&quot;long&quot;,   &quot;required&quot;:false},
      {&quot;id&quot;:6, &quot;name&quot;:&quot;avg_age_days&quot;,    &quot;type&quot;:&quot;double&quot;, &quot;required&quot;:false},
      {&quot;id&quot;:7, &quot;name&quot;:&quot;storage_class&quot;,   &quot;type&quot;:&quot;string&quot;, &quot;required&quot;:false},
      {&quot;id&quot;:8, &quot;name&quot;:&quot;recommendation&quot;,  &quot;type&quot;:&quot;string&quot;, &quot;required&quot;:false}
    ]
  }&#39;

# 테이블 확인
mc table list HOT infra-analytics inventory</code></pre>
<hr>
<h2 id="step-3--etl-parquet-→-aistor-iceberg-table">Step 3 — ETL: Parquet → AIStor Iceberg Table</h2>
<h3 id="pyiceberg-기반-etl-스크립트">PyIceberg 기반 ETL 스크립트</h3>
<pre><code class="language-python"># inventory_to_iceberg.py
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from pyiceberg.catalog import load_catalog
from pyiceberg.expressions import GreaterThanOrEqual
from datetime import date
import boto3
import os
from pathlib import Path

# ─────────────────────────────────────────────────────────
# 1. AIStor Iceberg Catalog 연결
# ─────────────────────────────────────────────────────────
catalog = load_catalog(
    &quot;aistor&quot;,
    **{
        &quot;uri&quot;: &quot;http://hot-aistor.example.com:9000/_iceberg&quot;,
        &quot;warehouse&quot;: &quot;infra-analytics&quot;,
        &quot;rest.sigv4-enabled&quot;: &quot;true&quot;,
        &quot;rest.signing-name&quot;: &quot;s3tables&quot;,
        &quot;rest.signing-region&quot;: &quot;local&quot;,
        &quot;s3.access-key-id&quot;: os.environ[&quot;MINIO_ACCESS_KEY&quot;],
        &quot;s3.secret-access-key&quot;: os.environ[&quot;MINIO_SECRET_KEY&quot;],
        &quot;s3.endpoint&quot;: &quot;http://hot-aistor.example.com:9000&quot;
    }
)

# ─────────────────────────────────────────────────────────
# 2. 오늘 날짜 Inventory Parquet 파일 수집
# ─────────────────────────────────────────────────────────
s3 = boto3.client(
    &#39;s3&#39;,
    endpoint_url=&#39;http://hot-aistor.example.com:9000&#39;,
    aws_access_key_id=os.environ[&quot;MINIO_ACCESS_KEY&quot;],
    aws_secret_access_key=os.environ[&quot;MINIO_SECRET_KEY&quot;]
)

today = date.today()
prefix = f&quot;{today.isoformat()}/&quot;

# inventory-bucket에서 오늘 날짜 parquet 목록 수집
response = s3.list_objects_v2(Bucket=&#39;inventory-bucket&#39;, Prefix=prefix)
parquet_keys = [
    obj[&#39;Key&#39;] for obj in response.get(&#39;Contents&#39;, [])
    if obj[&#39;Key&#39;].endswith(&#39;.parquet&#39;)
]

# ─────────────────────────────────────────────────────────
# 3. Parquet 읽기 + 스키마 정규화
# ─────────────────────────────────────────────────────────
dfs = []
for key in parquet_keys:
    local_path = f&quot;/tmp/{Path(key).name}&quot;
    s3.download_file(&#39;inventory-bucket&#39;, key, local_path)
    df = pd.read_parquet(local_path)
    dfs.append(df)

raw_df = pd.concat(dfs, ignore_index=True)

# 컬럼 정규화 및 파생 컬럼 추가
raw_df[&#39;snapshot_date&#39;] = today
raw_df[&#39;cluster&#39;]       = &#39;hot-primary&#39;
raw_df[&#39;tier&#39;]          = raw_df[&#39;storage_class&#39;].apply(
    lambda x: &#39;cold&#39; if str(x).startswith(&#39;COLD&#39;) else &#39;hot&#39;
)
raw_df[&#39;size_bytes&#39;]    = raw_df[&#39;size&#39;].fillna(0).astype(&#39;int64&#39;)
raw_df[&#39;last_modified&#39;] = pd.to_datetime(raw_df[&#39;last_modified&#39;], utc=True)

# 필요 컬럼만 선택
iceberg_df = raw_df[[
    &#39;snapshot_date&#39;, &#39;cluster&#39;, &#39;bucket&#39;, &#39;key&#39;,
    &#39;size_bytes&#39;, &#39;last_modified&#39;, &#39;storage_class&#39;,
    &#39;is_latest&#39;, &#39;is_delete_marker&#39;,
    &#39;replication_status&#39;, &#39;version_id&#39;, &#39;tier&#39;
]]

# ─────────────────────────────────────────────────────────
# 4. PyArrow 변환 후 AIStor Iceberg Table에 Append
# ─────────────────────────────────────────────────────────
table = catalog.load_table(&quot;inventory.object_inventory&quot;)

arrow_table = pa.Table.from_pandas(iceberg_df)
table.append(arrow_table)

print(f&quot;✅ {len(iceberg_df):,}건 → inventory.object_inventory append 완료&quot;)

# ─────────────────────────────────────────────────────────
# 5. ILM 후보 집계 → ilm_candidates 테이블 갱신
# ─────────────────────────────────────────────────────────
from datetime import datetime, timezone

now = datetime.now(timezone.utc)
iceberg_df[&#39;age_days&#39;] = (now - iceberg_df[&#39;last_modified&#39;]).dt.days

# tier=hot &amp; age &gt; 30일 오브젝트만 집계
candidates = (
    iceberg_df[
        (iceberg_df[&#39;tier&#39;] == &#39;hot&#39;) &amp;
        (iceberg_df[&#39;age_days&#39;] &gt;= 30) &amp;
        (iceberg_df[&#39;is_latest&#39;] == True) &amp;
        (iceberg_df[&#39;is_delete_marker&#39;] != True)
    ]
    .assign(prefix=lambda df: df[&#39;key&#39;].str.split(&#39;/&#39;).str[0])
    .groupby([&#39;bucket&#39;, &#39;prefix&#39;, &#39;storage_class&#39;])
    .agg(
        object_count=(&#39;key&#39;, &#39;count&#39;),
        total_size_bytes=(&#39;size_bytes&#39;, &#39;sum&#39;),
        avg_age_days=(&#39;age_days&#39;, &#39;mean&#39;)
    )
    .reset_index()
)

candidates[&#39;snapshot_date&#39;] = today
candidates[&#39;recommendation&#39;] = candidates.apply(
    lambda r: &#39;TRANSITION_TO_COLD&#39; if r[&#39;avg_age_days&#39;] &gt;= 90
              else &#39;MONITOR&#39;, axis=1
)

ilm_table = catalog.load_table(&quot;inventory.ilm_candidates&quot;)
ilm_table.append(pa.Table.from_pandas(candidates))

print(f&quot;✅ {len(candidates):,}건 → inventory.ilm_candidates append 완료&quot;)</code></pre>
<h3 id="spark-기반-etl-대용량-처리-시">Spark 기반 ETL (대용량 처리 시)</h3>
<pre><code class="language-python"># spark_inventory_etl.py
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, lit, datediff, current_date, split

spark = SparkSession.builder \
    .appName(&quot;InventoryToIceberg&quot;) \
    .config(&quot;spark.sql.catalog.aistor&quot;, &quot;org.apache.iceberg.spark.SparkCatalog&quot;) \
    .config(&quot;spark.sql.catalog.aistor.catalog-impl&quot;,
            &quot;org.apache.iceberg.rest.RESTCatalog&quot;) \
    .config(&quot;spark.sql.catalog.aistor.uri&quot;,
            &quot;http://hot-aistor.example.com:9000/_iceberg&quot;) \
    .config(&quot;spark.sql.catalog.aistor.warehouse&quot;, &quot;infra-analytics&quot;) \
    .config(&quot;spark.sql.catalog.aistor.rest.sigv4-enabled&quot;, &quot;true&quot;) \
    .config(&quot;spark.sql.catalog.aistor.rest.signing-name&quot;, &quot;s3tables&quot;) \
    .config(&quot;spark.sql.catalog.aistor.rest.signing-region&quot;, &quot;local&quot;) \
    .config(&quot;spark.sql.catalog.aistor.rest.access-key-id&quot;, &quot;MINIO_ACCESS_KEY&quot;) \
    .config(&quot;spark.sql.catalog.aistor.rest.secret-access-key&quot;, &quot;MINIO_SECRET_KEY&quot;) \
    .config(&quot;spark.hadoop.fs.s3a.endpoint&quot;,
            &quot;http://hot-aistor.example.com:9000&quot;) \
    .config(&quot;spark.hadoop.fs.s3a.path.style.access&quot;, &quot;true&quot;) \
    .getOrCreate()

TODAY = &quot;2025-05-01&quot;

# 오늘 Inventory Parquet 읽기
raw = spark.read.parquet(
    f&quot;s3a://inventory-bucket/{TODAY}/**/*.parquet&quot;
)

# 정규화
enriched = raw \
    .withColumn(&quot;snapshot_date&quot;, lit(TODAY).cast(&quot;date&quot;)) \
    .withColumn(&quot;cluster&quot;,       lit(&quot;hot-primary&quot;)) \
    .withColumn(&quot;size_bytes&quot;,    col(&quot;size&quot;).cast(&quot;long&quot;)) \
    .withColumn(&quot;tier&quot;,
        when(col(&quot;storage_class&quot;).startswith(&quot;COLD&quot;), &quot;cold&quot;)
        .otherwise(&quot;hot&quot;)
    )

# Iceberg Table에 Append
enriched.select(
    &quot;snapshot_date&quot;, &quot;cluster&quot;, &quot;bucket&quot;, &quot;key&quot;,
    &quot;size_bytes&quot;, &quot;last_modified&quot;, &quot;storage_class&quot;,
    &quot;is_latest&quot;, &quot;is_delete_marker&quot;,
    &quot;replication_status&quot;, &quot;version_id&quot;, &quot;tier&quot;
).writeTo(&quot;aistor.inventory.object_inventory&quot;).append()

print(&quot;✅ object_inventory append 완료&quot;)

# ILM 후보 집계
spark.sql(&quot;&quot;&quot;
    INSERT INTO aistor.inventory.ilm_candidates
    SELECT
        CAST(&#39;{today}&#39; AS DATE) AS snapshot_date,
        bucket,
        split(key, &#39;/&#39;)[0] AS prefix,
        COUNT(*)             AS object_count,
        SUM(size_bytes)      AS total_size_bytes,
        AVG(datediff(current_date(), DATE(last_modified))) AS avg_age_days,
        storage_class,
        CASE
          WHEN AVG(datediff(current_date(), DATE(last_modified))) &gt;= 90
          THEN &#39;TRANSITION_TO_COLD&#39;
          ELSE &#39;MONITOR&#39;
        END AS recommendation
    FROM aistor.inventory.object_inventory
    WHERE snapshot_date = CAST(&#39;{today}&#39; AS DATE)
      AND tier = &#39;hot&#39;
      AND is_latest = true
      AND is_delete_marker = false
      AND datediff(current_date(), DATE(last_modified)) &gt;= 30
    GROUP BY bucket, split(key, &#39;/&#39;)[0], storage_class
&quot;&quot;&quot;.format(today=TODAY))</code></pre>
<hr>
<h2 id="step-4--활용-쿼리-설계-trino-기준">Step 4 — 활용 쿼리 설계 (Trino 기준)</h2>
<h3 id="4-1-tier별-용량-현황">4-1. Tier별 용량 현황</h3>
<pre><code class="language-sql">-- Hot/Cold 용량 및 오브젝트 수 현황
SELECT
    snapshot_date,
    cluster,
    tier,
    storage_class,
    COUNT(*)                          AS object_count,
    SUM(size_bytes) / 1e15            AS size_petabytes,
    AVG(size_bytes) / 1e6             AS avg_size_mb
FROM aistor.inventory.object_inventory
WHERE snapshot_date = CURRENT_DATE
GROUP BY snapshot_date, cluster, tier, storage_class
ORDER BY size_petabytes DESC;</code></pre>
<h3 id="4-2-ilm-전환-우선순위-대상-식별">4-2. ILM 전환 우선순위 대상 식별</h3>
<pre><code class="language-sql">-- Cold로 내릴 수 있는 Hot 데이터 (30일 이상 미접근 추정)
SELECT
    bucket,
    prefix,
    object_count,
    ROUND(total_size_bytes / 1e12, 2)  AS size_tb,
    ROUND(avg_age_days)                AS avg_age_days,
    recommendation
FROM aistor.inventory.ilm_candidates
WHERE snapshot_date = CURRENT_DATE
  AND recommendation = &#39;TRANSITION_TO_COLD&#39;
ORDER BY total_size_bytes DESC
LIMIT 50;</code></pre>
<h3 id="4-3-버킷별-cold-전환-시뮬레이션">4-3. 버킷별 Cold 전환 시뮬레이션</h3>
<pre><code class="language-sql">-- 30/60/90일 기준 전환 시 용량 절감 효과
SELECT
    bucket,
    SUM(CASE WHEN age_days &gt;= 30 THEN size_bytes ELSE 0 END) / 1e12 AS cold_30d_tb,
    SUM(CASE WHEN age_days &gt;= 60 THEN size_bytes ELSE 0 END) / 1e12 AS cold_60d_tb,
    SUM(CASE WHEN age_days &gt;= 90 THEN size_bytes ELSE 0 END) / 1e12 AS cold_90d_tb,
    SUM(size_bytes) / 1e12                                           AS total_tb
FROM (
    SELECT
        bucket,
        size_bytes,
        datediff(day, date(last_modified), current_date) AS age_days
    FROM aistor.inventory.object_inventory
    WHERE snapshot_date = CURRENT_DATE
      AND tier = &#39;hot&#39;
      AND is_latest = true
      AND is_delete_marker = false
)
GROUP BY bucket
ORDER BY cold_90d_tb DESC;</code></pre>
<h3 id="4-4-날짜별-증가-추이-iceberg-time-travel-활용">4-4. 날짜별 증가 추이 (Iceberg Time Travel 활용)</h3>
<pre><code class="language-sql">-- 주간 용량 증가 트렌드 (Iceberg의 snapshot 이력 활용)
SELECT
    snapshot_date,
    tier,
    SUM(size_bytes) / 1e15 AS total_petabytes
FROM aistor.inventory.object_inventory
WHERE snapshot_date &gt;= CURRENT_DATE - INTERVAL &#39;30&#39; DAY
GROUP BY snapshot_date, tier
ORDER BY snapshot_date, tier;</code></pre>
<hr>
<h2 id="step-5--주기적-실행-자동화">Step 5 — 주기적 실행 자동화</h2>
<pre><code class="language-bash"># crontab 예시: 매일 새벽 3시 실행
# 1. Inventory 생성 → 완료 대기
# 2. ETL 실행 → Iceberg Table append
# 3. 오래된 raw parquet 정리

# /etc/cron.d/minio-inventory-etl
0 3 * * * root /opt/scripts/run_inventory_etl.sh &gt;&gt; /var/log/inventory_etl.log 2&gt;&amp;1</code></pre>
<pre><code class="language-bash">#!/bin/bash
# run_inventory_etl.sh

DATE=$(date +%Y-%m-%d)
echo &quot;[$DATE] Inventory ETL 시작&quot;

# 1. Inventory 완료 확인
mc inventory status HOT/&lt;inventory-id&gt; | grep -q &quot;Completed&quot; || {
  echo &quot;Inventory 아직 미완료, 재시도&quot;
  exit 1
}

# 2. ETL 실행
python3 /opt/scripts/inventory_to_iceberg.py

# 3. 이전 Raw Parquet 정리 (30일 이전)
mc find HOT/inventory-bucket \
  --older-than 30d \
  --name &quot;*.parquet&quot; | xargs -I{} mc rm HOT/{}

echo &quot;[$DATE] ETL 완료&quot;</code></pre>
<hr>
<h2 id="활용-방안-종합">활용 방안 종합</h2>
<p>AIStor Tables는 외부 카탈로그 서비스나 메타데이터 DB 없이 MinIO AIStor 내부에서 직접 Iceberg 테이블을 생성·관리·쿼리할 수 있고, Spark, Trino, Dremio, Starburst 같은 클라이언트에서 Iceberg REST Catalog나 S3 API 양쪽으로 접근 가능하다.</p>
<p>이를 활용한 용도를 구체적으로 정리하면:</p>
<table>
<thead>
<tr>
<th>활용 분야</th>
<th>구체 쿼리 내용</th>
<th>의사결정 지원</th>
</tr>
</thead>
<tbody><tr>
<td>ILM 정책 수립</td>
<td>버킷/prefix별 age 분포 분석</td>
<td>30/60/90일 전환 기준 수치 근거 확보</td>
</tr>
<tr>
<td>Cold Tier 용량 산정</td>
<td>pool0~pool2 수용 가능 용량 vs 전환 대상</td>
<td>5월/8월 구축 규모 검증</td>
</tr>
<tr>
<td>Hot Tier 공간 확보</td>
<td>전환 후 Hot 잔여 용량 예측</td>
<td>Hot 클러스터 증설 여부 판단</td>
</tr>
<tr>
<td>ILM 전환 모니터링</td>
<td>storage_class 변화 추이</td>
<td>실제 Cold 전환 검증</td>
</tr>
<tr>
<td>Delta Sharing 활용</td>
<td>Delta Sharing Protocol로 Iceberg 테이블을 Databricks 등 외부에 공유 가능</td>
<td>고객사 보고/대시보드 연동</td>
</tr>
</tbody></table>
<hr>
<h2 id="핵심-주의사항">핵심 주의사항</h2>
<blockquote>
<p>AIStor Tables는 테이블 위치(location)를 MinIO가 직접 관리하며, 사용자가 커스텀 위치를 지정할 수 없음 → Parquet을 직접 테이블 위치에 복사하는 방식 사용 불가, 반드시 PyIceberg/Spark 등 클라이언트 라이브러리를 통한 append 방식 사용</p>
</blockquote>
<blockquote>
<p>커밋은 낙관적 동시성 제어(optimistic locking)로 처리되며, 충돌 시 지수 백오프로 재시도 권장 — 병렬 ETL 실행 시 주의</p>
</blockquote>
<blockquote>
<p>AIStor Tables 기능은 <code>minio RELEASE.2026-02-02T23-40-11Z</code> 이상에서 지원 — 버전 확인 필수</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A27c]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A27c</link>
            <guid>https://velog.io/@youngkyoo_kim/26A27c</guid>
            <pubDate>Mon, 27 Apr 2026 08:29:03 GMT</pubDate>
            <description><![CDATA[<p>이제 Replication 페이지와 Object Tiering 세부 페이지도 가져올게요.공식 docs 내용을 다 확인했어. 이제 요건에 맞는 Best Practice 안들을 정리할게.</p>
<hr>
<h1 id="minio-aistor-cold-tier-best-practice-안">MinIO AIStor Cold Tier Best Practice 안</h1>
<blockquote>
<p>📌 Docs에서 확인한 중요 제약사항 먼저</p>
</blockquote>
<p>MinIO AIStor의 Object Tiering은 <strong>백업/복구 솔루션이 아님</strong> — Cold tier로 전환된 데이터는 Hot tier 메타데이터와 강하게 연결되어 있어서, Hot 장애 시 Cold 데이터만으로 복구 불가능함.</p>
<p>Cold target bucket에는 반드시 <strong>MinIO가 독점 접근</strong>해야 하고, 외부 변경/삭제/별도 ILM 규칙을 절대 설정하면 안 됨.</p>
<hr>
<h2 id="안-a-ilm-tiering-only-단순-구성-권고">안 A: ILM Tiering Only (단순 구성, 권고)</h2>
<p><strong>대상</strong>: 정책이 아무것도 없는 지금 상황에서 가장 빠르게 안정적으로 시작할 수 있는 구성</p>
<pre><code>[Hot Cluster - 기존]                   [Cold Cluster - 신규]
128 nodes (6.8TiB x20)                 Server Pool A: pool0 (14nodes, 7TiB SATA)
                                        Server Pool B: pool1 (14nodes, 3.5TiB SAS)
    │                                   Server Pool C: pool2 (130nodes, 3.5TiB SATA) ← 8월
    │  ILM Transition (자동)
    └──────────────────────────────────▶ COLD-TIER (remote tier)
                                            └─ bucket: cold-archive
                                               prefix: hot/{deployment-name}/</code></pre><h3 id="cold-cluster-구성">Cold Cluster 구성</h3>
<pre><code class="language-bash"># 5월: pool0 + pool1 동시 기동 (Cold 클러스터)
minio server \
  https://cold-pool0-{1...14}.example.com/data{1...20} \
  https://cold-pool1-{1...14}.example.com/data{1...20} \
  --address :9000

# 8월: pool2 추가 (클러스터 재시작 필요 → 점검 윈도우 확보)
minio server \
  https://cold-pool0-{1...14}.example.com/data{1...20} \
  https://cold-pool1-{1...14}.example.com/data{1...20} \
  https://cold-pool2-{1...130}.example.com/data{1...20}</code></pre>
<h3 id="remote-tier-등록-및-ilm-설정">Remote Tier 등록 및 ILM 설정</h3>
<pre><code class="language-bash"># 1. Cold 클러스터를 Hot에 Remote Tier로 등록
mc ilm tier add minio HOT/COLD-TIER \
  --endpoint https://cold-aistor.example.com \
  --access-key &lt;cold-svc-access-key&gt; \
  --secret-key &lt;cold-svc-secret-key&gt; \
  --bucket cold-archive \
  --prefix hot/prod/            # 선택적 human-readable prefix (운영/진단 편의)

# Tier 상태 확인
mc ilm tier ls HOT
mc ilm tier check HOT/COLD-TIER

# 2. ILM 전환 규칙 (버킷별 적용)
# 기본: 30일 후 Cold 전환
mc ilm rule add HOT/my-bucket \
  --transition-days 30 \
  --transition-tier COLD-TIER

# prefix별 차등 (로그 7일, 백업 90일)
mc ilm rule add HOT/my-bucket \
  --prefix &quot;logs/&quot; \
  --transition-days 7 \
  --transition-tier COLD-TIER

mc ilm rule add HOT/my-bucket \
  --prefix &quot;backup/&quot; \
  --transition-days 90 \
  --transition-tier COLD-TIER

# 현재 상태 확인 (ls로 tier 표시)
# STANDARD = Hot에 있음, COLD-TIER = Cold로 전환됨
mc ls HOT/my-bucket</code></pre>
<h3 id="이-안의-특징">이 안의 특징</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>장점</strong></td>
<td>구성 단순, 관리 일원화, 즉시 시작 가능</td>
</tr>
<tr>
<td><strong>단점</strong></td>
<td>Cold는 DR 불가 (Hot 전체 장애 시 Cold 데이터 접근 불가)</td>
</tr>
<tr>
<td><strong>적합 케이스</strong></td>
<td>정책 수립 초기, Hot 장애 복구보다 비용 절감이 우선인 경우</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ Cold target bucket에 별도 ILM 규칙(expiration, tiering) 절대 금지 — Cold 클러스터 생성 후 해당 bucket은 Hot의 MinIO만 접근해야 함</p>
</blockquote>
<hr>
<h2 id="안-b-ilm-tiering--versioning--noncurrent-version-관리-권장-중급">안 B: ILM Tiering + Versioning + noncurrent version 관리 (권장 중급)</h2>
<p><strong>대상</strong>: 실수 삭제 방지, 버전 관리가 필요한 경우 (추후 컴플라이언스 대비 포함)</p>
<pre><code>[Hot Cluster]
  Versioned Buckets
  ├── current version ──[30일 후]──▶ Cold WARM-TIER
  ├── noncurrent version ──[7일 후]──▶ Cold WARM-TIER (또는 expire)
  └── delete marker ──[expire-delete-marker]──▶ 정리</code></pre><h3 id="핵심-설정">핵심 설정</h3>
<pre><code class="language-bash"># 1. 버킷 버전닝 활성화
mc version enable HOT/my-bucket

# 2. Current version → Cold 전환
mc ilm rule add HOT/my-bucket \
  --transition-days 30 \
  --transition-tier COLD-TIER

# 3. Noncurrent version → Cold 전환 (버전 누적 방지)
mc ilm rule add HOT/my-bucket \
  --noncurrent-transition-days 7 \
  --noncurrent-transition-tier COLD-TIER

# 4. Delete marker 정리 (버전이 없는 delete marker만)
mc ilm rule add HOT/my-bucket \
  --expire-delete-marker

# 5. 규칙 확인
mc ilm rule ls HOT/my-bucket</code></pre>
<h3 id="버전닝--ilm-동작-흐름">버전닝 + ILM 동작 흐름</h3>
<pre><code>Day 0:  Object 생성 → Hot(STANDARD)
Day 30: ILM 스캐너 감지 → Cold로 전환 (Hot에는 stub/포인터만 남음)
Day 30: 클라이언트 GET → MinIO가 투명하게 Cold에서 자동 recall
        (앱 변경 불필요)

버전 업데이트 시:
  이전 버전 → 7일 후 Cold 전환 또는 expire
  최신 버전 → 30일 후 Cold 전환</code></pre><blockquote>
<p>📌 ILM 스캐너는 low-priority 프로세스로, 고부하 시 전환 시점이 설정값보다 늦어질 수 있음 — 정확한 SLA가 필요하면 여유 있는 일수 설정 필요</p>
</blockquote>
<h3 id="이-안의-특징-1">이 안의 특징</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>장점</strong></td>
<td>실수 삭제 보호, 버전 히스토리 유지, 컴플라이언스 대비</td>
</tr>
<tr>
<td><strong>단점</strong></td>
<td>버전 누적 시 Hot 용량 소비 증가 (noncurrent 관리 필수)</td>
</tr>
<tr>
<td><strong>적합 케이스</strong></td>
<td>데이터 변경이 잦거나, 규정상 이력 보존이 필요한 경우</td>
</tr>
</tbody></table>
<hr>
<h2 id="안-c-ilm-tiering--bucket-replication-for-dr-hot-이중화">안 C: ILM Tiering + Bucket Replication for DR (Hot 이중화)</h2>
<p><strong>대상</strong>: Hot 클러스터 장애 대비 DR이 필요하고, Cold는 비용 최적화만 하는 경우</p>
<pre><code>[Hot-Primary - Site A]          [Hot-DR - Site B]
  128 nodes                       별도 MinIO AIStor
       │ Bucket Replication (Active-Passive)
       │──────────────────────────▶ 동일 버킷 복제
       │
       │ ILM Transition
       └──────────────────────────▶ [Cold Cluster]
                                      pool0 + pool1 + pool2</code></pre><h3 id="설정-방향">설정 방향</h3>
<pre><code class="language-bash"># === Bucket Replication: Hot-Primary → Hot-DR ===
# Hot-DR에 서비스 계정 생성 후, Hot-Primary에서 복제 설정

mc replicate add HOT_PRIMARY/my-bucket \
  --remote-bucket my-bucket \
  --replication-mode async \
  &quot;https://&lt;hot-dr-user&gt;:&lt;hot-dr-pass&gt;@hot-dr.example.com&quot;

# 복제 상태 모니터링
mc replicate status HOT_PRIMARY/my-bucket

# === ILM Tiering: Hot-Primary → Cold (안 A/B와 동일) ===
mc ilm tier add minio HOT_PRIMARY/COLD-TIER \
  --endpoint https://cold-aistor.example.com \
  --bucket cold-archive \
  --prefix hot/primary/</code></pre>
<h3 id="⚠️-중요-제약사항-docs-기반">⚠️ 중요 제약사항 (Docs 기반)</h3>
<p>ILM Expiration으로 삭제된 오브젝트는 <strong>복제 대상이 아님</strong> — Bucket Replication과 ILM Expiration을 함께 쓸 경우, Hot-DR에 delete가 전파되지 않으니 별도 처리 필요</p>
<p>Site Replication은 모든 피어 클러스터가 <strong>동일 IDP, 동일 버전, 동일 암호화 설정</strong>을 사용해야 함 — 기존 Hot과 신규 Cold 클러스터 간 Site Replication은 이 조건 불일치로 부적합</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>장점</strong></td>
<td>Hot 장애 시 DR-Site로 페일오버 가능, Cold는 비용 최적화만</td>
</tr>
<tr>
<td><strong>단점</strong></td>
<td>Hot DR 클러스터 추가 비용, 설정 복잡도 증가</td>
</tr>
<tr>
<td><strong>적합 케이스</strong></td>
<td>SLA가 있거나 Hot 클러스터 단일 장애점이 허용 안 되는 경우</td>
</tr>
</tbody></table>
<hr>
<h2 id="3개-안-비교-요약">3개 안 비교 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>안 A (Tiering Only)</th>
<th>안 B (Tiering + Versioning)</th>
<th>안 C (Tiering + DR)</th>
</tr>
</thead>
<tbody><tr>
<td>구성 복잡도</td>
<td>낮음</td>
<td>중간</td>
<td>높음</td>
</tr>
<tr>
<td>Cold 장애 시</td>
<td>Hot에서 stub 접근 실패</td>
<td>동일</td>
<td>동일</td>
</tr>
<tr>
<td>Hot 전체 장애 시</td>
<td>복구 불가</td>
<td>복구 불가</td>
<td>Hot-DR로 복구 가능</td>
</tr>
<tr>
<td>실수 삭제 방어</td>
<td>없음</td>
<td>있음 (버전)</td>
<td>있음 (복제)</td>
</tr>
<tr>
<td>비용</td>
<td>최소</td>
<td>소폭 증가</td>
<td>Hot DR 추가 비용</td>
</tr>
<tr>
<td><strong>지금 고객사 적합도</strong></td>
<td>⭐⭐⭐</td>
<td>⭐⭐</td>
<td>⭐ (정책 없어서 시기상조)</td>
</tr>
</tbody></table>
<hr>
<h2 id="어떤-안으로-시작할지-결정-기준">어떤 안으로 시작할지 결정 기준</h2>
<pre><code>Q1. Hot 전체 장애 시 데이터 접근이 비즈니스 크리티컬한가?
  YES → 안 C (Hot DR 구성 필요)
  NO  → 다음 질문

Q2. 실수 삭제나 데이터 이력 보존이 필요한가?
  YES → 안 B (Versioning + ILM)
  NO  → 안 A (ILM Tiering Only, 5월 구축에 가장 적합)</code></pre><p><strong>지금 당장 실행 가능한 최소 액션 (5월 전 필수):</strong></p>
<pre><code>[ ] Cold 클러스터 서비스 계정 분리 생성 (Hot과 별도)
[ ] Cold target bucket 사전 생성 + deny all 정책 (ILM 서비스 계정만 허용)
[ ] ILM 전환 일수 고객사 합의 (최소 1개 버킷이라도)
[ ] Cold 클러스터 점검 윈도우 확보 (8월 pool2 추가 시 재시작 필요)
[ ] mc ilm tier check 로 연결 검증 자동화 방안 마련</code></pre><p>더 구체적으로 파고들 부분(예: noncurrent version expire 설정, 서비스 계정 policy JSON, 버킷별 prefix 체계)이 있으면 말씀해 주세요!</p>
<p>===</p>
<p>제시해주신 MinIO AIStor의 <strong>Versioning, ILM(Lifecycle), Replication</strong> 공식 문서를 바탕으로, 현재 운영 중인 128대(Hot)와 향후 도입될 158대(Pool 0, 1, 2)의 대규모 인프라 환경에 최적화된 <strong>3가지 Best Practice 시나리오</strong>를 설계해 드립니다.</p>
<hr>
<h2 id="scenario-1-초고성능-ai-모델-및-핵심-자산-보호">Scenario 1. 초고성능 AI 모델 및 핵심 자산 보호</h2>
<p><strong>목표:</strong> 데이터 유실 제로(Zero Data Loss) 및 즉각적인 가용성 확보
<strong>대상:</strong> AI 모델 가중치(Weights), AD/Keycloak 연동 설정, 핵심 Iceberg 메타데이터</p>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">정책 설정 (Best Practice)</th>
<th align="left">기대 효과 및 이유</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Versioning</strong></td>
<td align="left"><strong>Enabled</strong></td>
<td align="left">실수에 의한 덮어쓰기나 삭제 시 즉시 롤백 가능.</td>
</tr>
<tr>
<td align="left"><strong>Lifecycle</strong></td>
<td align="left"><strong>NoncurrentVersionExpiration: 30 days</strong></td>
<td align="left">최신이 아닌 버전은 30일 후 자동 삭제하여 무분별한 용량 낭비 방지.</td>
</tr>
<tr>
<td align="left"><strong>Replication</strong></td>
<td align="left"><strong>Site Replication (Synchronous)</strong></td>
<td align="left">128대 Hot 노드 내 또는 별도 가용영역 간 동기 복제. 서비스 연속성 극대화.</td>
</tr>
<tr>
<td align="left"><strong>Tiering</strong></td>
<td align="left"><strong>None (Keep in Hot)</strong></td>
<td align="left">모델 학습 및 서빙 시 3,000 RPS 성능을 유지하기 위해 NVMe(Hot Tier)에 상주.</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>DevOps Tip:</strong> 초고성능 요청이 발생하는 버킷에 Versioning을 걸면 <strong>Delete Marker</strong>가 쌓여 <code>LIST</code> 성능이 저하될 수 있습니다. 주기적으로 <code>ExpiredObjectDeleteMarkers</code>를 청소하는 정책을 세트로 구성해야 합니다.</p>
</blockquote>
<hr>
<h2 id="scenario-2-대규모-데이터-레이크하우스-inventory-rightarrow-iceberg">Scenario 2. 대규모 데이터 레이크하우스 (Inventory $\rightarrow$ Iceberg)</h2>
<p><strong>목표:</strong> TCO(총소유비용) 절감 및 9PB급 대용량 효율적 관리
<strong>대상:</strong> Inventory API 수집 Parquet, Iceberg Data Files, 분석용 원천 데이터</p>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">정책 설정 (Best Practice)</th>
<th align="left">기대 효과 및 이유</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Versioning</strong></td>
<td align="left"><strong>Suspended</strong></td>
<td align="left">대량의 데이터가 수시로 업데이트되는 분석 환경에서 버전 관리는 I/O 부하와 스토리지 낭비가 극심함.</td>
</tr>
<tr>
<td align="left"><strong>Lifecycle</strong></td>
<td align="left"><strong>Transition (Hot $\rightarrow$ Pool 2)</strong></td>
<td align="left"><strong>생성 14일 후:</strong> 130대의 Cold 노드(Pool 2)로 데이터 자동 이동. Hot Tier(NVMe) 공간 확보.</td>
</tr>
<tr>
<td align="left"><strong>Replication</strong></td>
<td align="left"><strong>Bucket Replication (Asynchronous)</strong></td>
<td align="left">비동기 복제를 통해 3,000 RPS의 쓰기 성능에 영향을 주지 않으면서 데이터 백업 수행.</td>
</tr>
<tr>
<td align="left"><strong>Tiering</strong></td>
<td align="left"><strong>Active (Server-Side Tiering)</strong></td>
<td align="left">Polaris 카탈로그와 연계하여 엔진(Trino/Spark)이 어느 계층에 있든 투명하게 데이터를 조회하도록 설계.</td>
</tr>
</tbody></table>
<hr>
<h2 id="scenario-3-컴플라이언스-및-아카이브-로그-관리">Scenario 3. 컴플라이언스 및 아카이브 로그 관리</h2>
<p><strong>목표:</strong> 법적 규제 준수 및 장기 보존 데이터의 안정적 처리
<strong>대상:</strong> 시스템 액세스 로그(Keycloak, K8s), 감사 로그(Audit Logs)</p>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">정책 설정 (Best Practice)</th>
<th align="left">기대 효과 및 이유</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Versioning</strong></td>
<td align="left"><strong>Enabled (with Object Lock)</strong></td>
<td align="left">수정 불가능한 상태(WORM)를 유지하여 보안 감사 및 조작 방지 대응.</td>
</tr>
<tr>
<td align="left"><strong>Lifecycle</strong></td>
<td align="left"><strong>Expiration: 365 days</strong></td>
<td align="left">1년이 지난 로그는 자동으로 파기하여 스토리지 효율성 및 개인정보보호 규정 준수.</td>
</tr>
<tr>
<td align="left"><strong>Replication</strong></td>
<td align="left"><strong>Bucket Replication (Passive)</strong></td>
<td align="left">재해 복구(DR) 용도로만 원격지 Cold Pool에 복사본 유지.</td>
</tr>
<tr>
<td align="left"><strong>Tiering</strong></td>
<td align="left"><strong>Immediate Transition to Pool 2</strong></td>
<td align="left">생성 직후 또는 1일 후 바로 SATA SSD(Cold) 계층으로 이동시켜 비용 최소화.</td>
</tr>
</tbody></table>
<hr>
<h2 id="💡-아키텍트-관점의-통합-운영-설계-가이드">💡 아키텍트 관점의 통합 운영 설계 가이드</h2>
<h3 id="1-1000노드-확장을-위한-delete-marker-최적화">1. 1,000노드 확장을 위한 &#39;Delete Marker&#39; 최적화</h3>
<p>RPS가 높은 환경에서 Versioning을 사용하면 수많은 &#39;삭제 마커&#39;가 생성됩니다. 이는 <code>mc admin trace</code>에서 <code>LIST</code> 요청의 <code>Duration</code>을 늘리는 주범입니다. </p>
<ul>
<li><strong>해결책:</strong> ILM 정책에 <code>DelMarkerExpiration: true</code>를 포함하여, 데이터가 실제로 없는 빈 마커들을 24시간 내에 자동으로 소거하십시오.</li>
</ul>
<h3 id="2-8월-pool-2130대-도입-시-리밸런싱-전략">2. 8월 Pool 2(130대) 도입 시 리밸런싱 전략</h3>
<p>130대의 노드가 한꺼번에 투입되면 기존 Hot Tier에서 데이터가 대량으로 넘어가며 네트워크 대역폭(50Gbps 본딩)을 잠식할 수 있습니다.</p>
<ul>
<li><strong>해결책:</strong> ILM 정책의 <strong><code>Transition</code></strong> 작업을 업무 외 시간(Off-peak hours)에 실행되도록 스케줄링하거나, <code>mc admin config</code>를 통해 백그라운드 복제/티어링 속도를 제한(Throttle)하는 정책을 미리 수립해야 합니다.</li>
</ul>
<h3 id="3-복제-지연replication-lag-모니터링">3. 복제 지연(Replication Lag) 모니터링</h3>
<p>비동기 복제를 사용하면 3,000 RPS의 쓰기 속도를 복제 프로세스가 따라가지 못할 수 있습니다. </p>
<ul>
<li><strong>해결책:</strong> Prometheus가 없는 현재 상황에서는 <code>mc admin bucket remote status</code> 명령어를 주기적으로 실행하여 <strong><code>Pending</code></strong> 데이터 양이 늘어나는지 상시 확인하는 자동화 스크립트 배포를 권장합니다.</li>
</ul>
<hr>
<h3 id="최종-제언">최종 제언</h3>
<p>&quot;고객사에는 <strong>&#39;가용성 중심(Scenario 1)&#39;</strong>과 <strong>&#39;비용/용량 중심(Scenario 2)&#39;</strong> 버킷을 명확히 구분하여 제안하십시오. 특히 8월에 들어올 130대의 노드는 <strong>ILM Transition의 종착지</strong>로 설정하되, 대량 이동 시 발생할 I/O 부하를 고려하여 <strong>비동기 정책</strong>을 표준으로 가져가는 것이 3,000 RPS 서비스 안정성을 지키는 핵심입니다.&quot;</p>
<p>현재 어떤 시나리오가 고객사의 비즈니스 요구사항(예: 비용 절감 vs 데이터 보안)에 더 가깝다고 보시나요? 필요하신 시나리오에 대해 구체적인 <code>JSON</code> 정책 샘플을 작성해 드릴 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A27b]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A27b</link>
            <guid>https://velog.io/@youngkyoo_kim/26A27b</guid>
            <pubDate>Mon, 27 Apr 2026 07:37:18 GMT</pubDate>
            <description><![CDATA[<p>Lead DevOps이자 아키텍트로서 현재 128대(Hot) 규모에서 총 286대 규모로 확장하며, 성능(NVMe 추정)과 용량(SATA/SAS SSD)을 분리하는 매우 중요한 전환점에 서 계십니다. </p>
<p>단순히 용량을 늘리는 것을 넘어, <strong>데이터의 생애주기(Lifecycle)</strong>를 관리하고 <strong>비용 효율적인 대규모 스토리지(Cold Tier)</strong>를 구축하기 위한 전략적 아키텍처를 제안해 드립니다.</p>
<hr>
<h3 id="1-계층별-풀pool-정의-및-특성-분류">1. 계층별 풀(Pool) 정의 및 특성 분류</h3>
<p>먼저 추가되는 자원들의 특성을 기반으로 계층(Tier)을 논리적으로 분리해야 합니다.</p>
<table>
<thead>
<tr>
<th align="left">계층 (Tier)</th>
<th align="left">구성 자원</th>
<th align="left">특성 및 목적</th>
<th align="left">권장 Erasure Coding (EC)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Hot (기존)</strong></td>
<td align="left">128 Nodes (NVMe)</td>
<td align="left"><strong>초고성능.</strong> 실시간 AI 학습, 실시간 API(3,000 RPS) 처리 전용</td>
<td align="left">EC:4 or EC:8</td>
</tr>
<tr>
<td align="left"><strong>Warm (Pool 0, 1)</strong></td>
<td align="left">28 Nodes (SATA/SAS SSD)</td>
<td align="left"><strong>고신뢰성/중용량.</strong> 최근 30일 이내 분석 데이터, 체크포인트 저장</td>
<td align="left">EC:8</td>
</tr>
<tr>
<td align="left"><strong>Cold (Pool 2)</strong></td>
<td align="left">130 Nodes (SATA SSD)</td>
<td align="left"><strong>대규모 저장.</strong> 아카이브, 장기 보관 로그, 9PB급 벌크 데이터</td>
<td align="left"><strong>EC:16 (고효율)</strong></td>
</tr>
</tbody></table>
<hr>
<h3 id="2-아키텍처-구성-전략-server-side-tiering-vs-multi-pool">2. 아키텍처 구성 전략: Server-side Tiering vs Multi-Pool</h3>
<p>이 규모에서는 두 가지 전략 중 하나를 선택해야 합니다.</p>
<h4 id="전략-a-단일-네임스페이스-server-pools-확장">전략 A: 단일 네임스페이스 (Server-Pools) 확장</h4>
<p>모든 Pool을 하나의 MinIO 클러스터로 묶는 방식입니다.</p>
<ul>
<li><strong>방법:</strong> 기존 128노드 클러스터에 신규 노드들을 <code>Pool</code>로 계속 추가합니다.</li>
<li><strong>장점:</strong> 사용자는 단일 Endpoint만 바라보면 되며 관리가 단순합니다.</li>
<li><strong>단점:</strong> Hot Tier의 초고성능 I/O가 Cold Tier의 리밸런싱 작업 등에 영향을 받을 수 있습니다. (QoS 분리 어려움)</li>
</ul>
<h4 id="전략-b-계층형-티어링-lifecycle--remote-tiering---권장">전략 B: 계층형 티어링 (Lifecycle + Remote Tiering) - <strong>권장</strong></h4>
<p>Hot 클러스터와 Cold 클러스터를 논리적으로 분리하고, MinIO의 <strong>ILM(Information Lifecycle Management)</strong> 기능을 사용하는 방식입니다.</p>
<ul>
<li><strong>방법:</strong> 128노드(Hot)는 그대로 두고, 신규 158노드(Pool 0,1,2)를 별도의 <strong>&#39;Cold Cluster&#39;</strong>로 구성한 뒤 Hot 클러스터에서 <code>mc ilm tier add</code>로 연결합니다.</li>
<li><strong>장점:</strong> 하드웨어 장애 전파(Fault Domain)가 격리되며, Hot Tier의 RPS 성능을 100% 보호할 수 있습니다. 1,000노드 확장 시 관리가 훨씬 유연합니다.</li>
</ul>
<hr>
<h3 id="3-상세-정책policy-제안">3. 상세 정책(Policy) 제안</h3>
<p>고객사에 정책이 없는 상태이므로, 가장 표준적인 <strong>&quot;3-Step 데이터 전이&quot;</strong> 정책을 제안해 보세요.</p>
<h4 id="①-ilm-lifecycle-정책">① ILM (Lifecycle) 정책</h4>
<p>데이터의 &quot;신선도&quot;에 따라 자동으로 이동시킵니다.</p>
<ul>
<li><strong>Hot $\rightarrow$ Warm (30일):</strong> 생성된 지 30일이 지난 데이터는 SAS/SATA SSD 풀로 이동.</li>
<li><strong>Warm $\rightarrow$ Cold (90일):</strong> 90일이 지난 데이터는 Pool 2(130대 노드)로 이동하여 저장 밀도 극대화.</li>
<li><strong>Expiration (365일+):</strong> 필요 시 보존 기간이 지난 데이터는 자동 삭제.</li>
</ul>
<h4 id="②-replication-복제-정책">② Replication (복제) 정책</h4>
<p>AIStor의 안정성을 위해 중요한 버킷은 <strong>Active-Active</strong> 또는 <strong>Active-Passive</strong> 복제를 설정합니다.</p>
<ul>
<li><strong>Metadata 및 중요 Model:</strong> Hot Tier 내에서 2대 이상의 노드에 가용성을 확보하도록 EC 설정을 높게 가져가고, 필요시 원격지 클러스터로 Site Replication을 고려합니다.</li>
</ul>
<h4 id="③-erasure-coding-데이터-보호-최적화">③ Erasure Coding (데이터 보호) 최적화</h4>
<ul>
<li><strong>Pool 2 (130노드):</strong> 노드 수가 압도적으로 많으므로 <strong>EC:16</strong> 설정을 추천합니다. 이렇게 하면 16대의 노드가 동시에 장애가 나도 데이터가 안전하며, 저장 공간 효율(Overhead)을 최소화할 수 있습니다.</li>
</ul>
<hr>
<h3 id="4-devops-구현-및-운영-포인트">4. DevOps 구현 및 운영 포인트</h3>
<ul>
<li><strong>Cilium ClusterMesh 활용:</strong> Hot 클러스터와 Cold 클러스터를 분리하더라도, Cilium ClusterMesh를 통해 노드 간 통신을 Native 모드로 연결하여 티어링(Data Migration) 시 발생하는 네트워크 부하를 최적화하세요.</li>
<li><strong>ResourceQuota 적용:</strong> K8s Namespace마다 용량 제한을 설정했던 것처럼, 각 Pool이나 Tenant별로 <strong>Quota</strong>를 엄격히 적용하여 특정 Cold 데이터가 Hot Tier의 Buffer를 다 채우지 않도록 방지해야 합니다.</li>
<li><strong>5월/8월 단계적 도입:</strong> * <strong>5월:</strong> Pool 0, 1을 먼저 &#39;Warm Tier&#39;로 연동하여 티어링 로직이 3,000 RPS 환경에서 안정적인지 검증합니다.<ul>
<li><strong>8월:</strong> Pool 2(130대) 투입 시점에는 대규모 리밸런싱이 발생하므로, 이때 네트워크 본딩을 100Gbps로 올리는 것을 강력히 검토해야 합니다. (이때가 드라이브 효율이 진짜 필요한 시점입니다.)</li>
</ul>
</li>
</ul>
<h3 id="아키텍트로서의-한-줄-조언">아키텍트로서의 한 줄 조언</h3>
<p>&quot;130대의 Cold 노드는 향후 1,000노드 시대로 가는 거대한 <strong>&#39;데이터 댐&#39;</strong> 역할을 하게 될 것입니다. 지금 Hot Tier와 물리적/논리적으로 분리된 <strong>Tiering 구조</strong>를 잡아두지 않으면, 나중에 Cold 데이터의 인덱싱 부하가 현재의 3,000 RPS 성능을 갉아먹게 될 위험이 큽니다.&quot;</p>
<p>이 정책들을 고객사에 제안하실 때, &quot;성능 보호&quot;와 &quot;비용 절감&quot; 중 어느 쪽에 더 무게를 두고 설득하실 계획인가요? 그에 맞춰 상세 수치를 더 보완해 드릴 수 있습니다.</p>
<p>===</p>
<p>대규모 인프라를 운영하시는 아키텍트 입장에서 가장 큰 고민은 <strong>&quot;모든 데이터를 동일하게 취급할 수 없다&quot;</strong>는 점일 것입니다. 특히 파드당 3,000 RPS를 쏟아내는 Hot Tier의 성능을 보호하면서도, 8월에 들어올 130대의 거대한 Pool 2(Cold)를 효율적으로 활용하기 위한 정책별 Best Practice를 정리해 드립니다.</p>
<hr>
<h2 id="1-tiering-ilm-best-practice-성능-병목-방지">1. Tiering (ILM) Best Practice: &quot;성능 병목 방지&quot;</h2>
<p>데이터의 &#39;온도&#39;에 따라 Hot(NVMe) $\rightarrow$ Warm(SAS/SATA) $\rightarrow$ Cold(SATA)로 자동 이동시키는 정책입니다.</p>
<ul>
<li><strong>대상:</strong> 로그 데이터, 학습이 끝난 원천 데이터셋, 오래된 백업 파일.</li>
<li><strong>핵심 전략:</strong><ul>
<li><strong>Transition 기간 설정:</strong> Hot Tier(NVMe)는 용량 대비 비용이 매우 비쌉니다. 보통 <strong>15~30일</strong>을 기준으로 Warm Tier로 넘기는 것이 경제적입니다.</li>
<li><strong>Small Object 주의:</strong> 앞서 말씀하신 1KB 이하 객체가 티어링 대상에 포함되면, 이동 시 메타데이터 부하가 커집니다. <strong>&quot;최소 크기 필터&quot;</strong>를 걸어 일정 크기 이상의 객체만 티어링하는 것이 유리합니다.</li>
<li><strong>조회(Read) 패턴 고려:</strong> 티어링된 데이터를 읽을 때는 Cold Tier의 성능(SATA SSD)이 기준이 됩니다. 만약 빈번한 조회가 예상된다면 티어링보다는 복제(Replication)를 고려해야 합니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-replication-복제-best-practice-데이터-생존과-고가용성">2. Replication (복제) Best Practice: &quot;데이터 생존과 고가용성&quot;</h2>
<p>장애 발생 시 즉각적인 서비스 전환(Failover)이 필요한 경우 사용합니다.</p>
<ul>
<li><strong>대상:</strong> AI 모델 가중치(Weights), 서비스 크리티컬 설정 파일, 사용자 프로필.</li>
<li><strong>핵심 전략:</strong><ul>
<li><strong>Site Replication vs Bucket Replication:</strong> 128대 규모라면 <strong>Site Replication</strong>을 통해 IAM 설정, 정책, 버킷 구성을 통째로 동기화하는 것이 관리 포인트를 줄이는 방법입니다.</li>
<li><strong>Asynchronous(비동기) 방식:</strong> 3,000 RPS 환경에서 동기 복제는 Latency를 급증시킵니다. 반드시 비동기 복제를 사용하여 Hot Tier의 응답 속도를 유지하세요.</li>
<li><strong>Active-Active 구성:</strong> 5월에 구축될 28개 노드(Warm)를 단순 백업용이 아닌, 읽기 전용 엔드포인트로 활용하여 Hot Tier의 읽기 부하를 분산시키는 것도 좋은 전략입니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-versioning-best-practice-실수로부터의-보호">3. Versioning Best Practice: &quot;실수로부터의 보호&quot;</h2>
<p>데이터 삭제나 덮어쓰기 사고를 방지하기 위해 사용합니다.</p>
<ul>
<li><strong>대상:</strong> 설정 파일(Config), 코드 스니펫(Vibe Coding Zone 결과물), SOP 문서.</li>
<li><strong>핵심 전략:</strong><ul>
<li><strong>비용 경고:</strong> Versioning은 객체가 수정될 때마다 전체 크기만큼 저장 공간을 차지합니다. 무제한 버저닝은 130대 노드도 순식간에 채울 수 있습니다.</li>
<li><strong>Non-current Version Expiration:</strong> &quot;최신 버전이 아닌 객체는 7일 뒤에 삭제한다&quot;는 ILM 정책을 반드시 세트로 묶어야 합니다.</li>
<li><strong>Delete Marker 관리:</strong> 삭제된 파일의 &#39;삭제 마커&#39;도 성능에 영향을 줍니다. <code>ExpiredObjectDeleteMarkers</code> 옵션을 통해 찌꺼기를 주기적으로 청소해 줘야 합니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-정책별-best-practice-요약-가이드">4. 정책별 &quot;Best Practice&quot; 요약 가이드</h2>
<p>버킷의 성격에 따라 아래 메뉴판에서 선택하여 적용하시는 것을 추천합니다.</p>
<table>
<thead>
<tr>
<th align="left">워크로드 유형</th>
<th align="left">추천 정책 조합</th>
<th align="left">기대 효과</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>실시간 AI 서빙</strong></td>
<td align="left"><strong>Hot Only</strong> (No Policy)</td>
<td align="left">최저 Latency 보장, 3,000 RPS 최적화</td>
</tr>
<tr>
<td align="left"><strong>분석용 데이터 레이크</strong></td>
<td align="left"><strong>Tiering</strong> (30d Hot $\rightarrow$ Cold)</td>
<td align="left">NVMe 공간 확보, 대용량 데이터 경제적 보관</td>
</tr>
<tr>
<td align="left"><strong>핵심 자산 (Gold)</strong></td>
<td align="left"><strong>Replication + Versioning</strong></td>
<td align="left">데이터 유실 제로, 24/7 서비스 연속성</td>
</tr>
<tr>
<td align="left"><strong>개발 환경 (Sandbox)</strong></td>
<td align="left"><strong>Versioning</strong> (Short-term)</td>
<td align="left">코드 롤백 가능, 휴먼 에러 방지</td>
</tr>
<tr>
<td align="left"><strong>아카이브 / 컴플라이언스</strong></td>
<td align="left"><strong>Tiering + Object Lock</strong></td>
<td align="left">법적 규제 준수, 장기 저가 저장</td>
</tr>
</tbody></table>
<hr>
<h3 id="💡-아키텍트로서의-추가-조언-lead-devops-관점">💡 아키텍트로서의 추가 조언 (Lead DevOps 관점)</h3>
<ol>
<li><strong>Prefix 기반 관리:</strong> 버킷 하나에 모든 정책을 다 넣기보다는, <code>bucket/logs/*</code>는 티어링, <code>bucket/configs/*</code>는 버저닝 하는 식으로 <strong>Prefix 단위</strong>로 ILM을 세분화하는 것이 가장 깔끔합니다.</li>
<li><strong>모니터링 우선순위:</strong> 복제(Replication)를 걸면 네트워크 트래픽이 2배가 됩니다. LACP 50G 본딩 상태에서 <strong>Replication Lag</strong>이 발생하는지 Hubble이나 <code>mc admin trace</code>로 상시 감시해야 합니다.</li>
<li><strong>8월 대규모 확장(Pool 2) 대비:</strong> 130대 노드가 들어오는 시점에는 Hot Tier에서 Cold Tier로 대량의 데이터 이동이 발생할 것입니다. 이때를 위해 지금부터 <strong>데이터 마이그레이션 스케줄링(Batch Job)</strong>에 대한 정책 가이드를 고객사에 미리 전달해 두시는 것이 좋습니다.</li>
</ol>
<p>현재 고객사에서 가장 우선순위가 높은 데이터는 무엇인가요? 예를 들어 &quot;AI 모델 데이터&quot;라면 복제를 우선으로, &quot;로그&quot;라면 티어링을 우선으로 설계안을 잡아드리면 설득력이 더 높아질 것 같습니다. 추가로 구체화가 필요한 사례가 있다면 말씀해 주세요!</p>
<p>===</p>
<h1 id="cold-tier-추가-구성-및-hot-tier-연계-전략">Cold Tier 추가 구성 및 Hot Tier 연계 전략</h1>
<h2 id="1-전체-용량-먼저-파악">1. 전체 용량 먼저 파악</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>노드 수</th>
<th>디스크</th>
<th>Raw 용량</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Hot tier</td>
<td>128</td>
<td>6.8TiB × 20</td>
<td>~17.4 PiB</td>
<td>기존</td>
</tr>
<tr>
<td>pool0</td>
<td>14</td>
<td>7.0TiB × 20</td>
<td>~1.96 PiB</td>
<td>5월, SATA SSD</td>
</tr>
<tr>
<td>pool1</td>
<td>14</td>
<td>3.5TiB × 20</td>
<td>~0.98 PiB</td>
<td>5월, SAS SSD</td>
</tr>
<tr>
<td>pool2</td>
<td>130</td>
<td>3.5TiB × 20</td>
<td>~9.1 PiB</td>
<td>8월, SATA SSD</td>
</tr>
<tr>
<td><strong>Cold 합계</strong></td>
<td><strong>158</strong></td>
<td>—</td>
<td><strong>~12 PiB</strong></td>
<td></td>
</tr>
</tbody></table>
<hr>
<h2 id="2-pool-구성-전략">2. Pool 구성 전략</h2>
<h3 id="핵심-원칙-device-class-분리-→-storage-class-매핑">핵심 원칙: Device Class 분리 → Storage Class 매핑</h3>
<pre><code>[Hot Tier]                    [Cold Tier]
NVMe/SAS HDD (128 nodes)     SATA SSD pool0 (14 nodes)
                              SAS  SSD pool1 (14 nodes)
                              SATA SSD pool2 (130 nodes, 8월)</code></pre><h3 id="pool0--pool1--pool2-각각-별도-crush-rule">pool0 / pool1 / pool2 각각 별도 CRUSH Rule</h3>
<p>pool0과 pool1은 같은 시기에 들어오지만 <strong>매체 특성이 다름</strong> (SATA vs SAS) → 반드시 분리</p>
<pre><code class="language-yaml"># 예시 (Ceph 기준)
device_class: sata-ssd   → pool0, pool2
device_class: sas-ssd    → pool1
device_class: hdd/nvme   → hot tier</code></pre>
<h3 id="erasure-coding-적용-권고-cold는-비용-최우선">Erasure Coding 적용 권고 (Cold는 비용 최우선)</h3>
<table>
<thead>
<tr>
<th>Pool</th>
<th>권고 EC 프로파일</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>pool0 (14 nodes)</td>
<td><code>k=10, m=4</code></td>
<td>노드 수 여유, 공간효율 71%</td>
</tr>
<tr>
<td>pool1 (14 nodes)</td>
<td><code>k=10, m=4</code></td>
<td>동일</td>
</tr>
<tr>
<td>pool2 (130 nodes)</td>
<td><code>k=12, m=4</code> or <code>k=16, m=4</code></td>
<td>노드 수 충분, 공간효율 최대화</td>
</tr>
<tr>
<td>hot tier</td>
<td>기존 정책 유지 (보통 <code>3-replica</code> or <code>k=8,m=3</code>)</td>
<td>성능 우선</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ pool1은 SAS SSD로 <strong>랜덤 I/O가 SATA보다 우수</strong> → EC rebuild 시 부하 측면에서 유리</p>
</blockquote>
<hr>
<h2 id="3-object-store--bucket-구성">3. Object Store / Bucket 구성</h2>
<h3 id="storage-class-체계-설계-s3-호환-기준">Storage Class 체계 설계 (S3 호환 기준)</h3>
<pre><code>STANDARD          → Hot tier (기존)
STANDARD_IA       → pool0 (7TiB SATA, 접근 빈도 중간)
GLACIER           → pool1 + pool2 (저빈도 아카이브)</code></pre><p>또는 용도 명확화 시:</p>
<pre><code>HOT               → hot tier
WARM              → pool0
COLD              → pool1
ARCHIVE           → pool2 (8월 이후)</code></pre><h3 id="placement-target-설정">Placement Target 설정</h3>
<pre><code>zonegroup → zone → placement_target
  ├── default-placement   (hot)
  ├── warm-placement      (pool0)
  ├── cold-placement      (pool1)
  └── archive-placement   (pool2, 8월 이후 추가)</code></pre><p>각 placement target에 <strong>index pool / data pool / data_extra pool</strong> 을 해당 device class pool로 매핑</p>
<hr>
<h2 id="4-hot-↔-cold-관계-설계">4. Hot ↔ Cold 관계 설계</h2>
<h3 id="지금-당장-결정해야-할-구조적-선택지">지금 당장 결정해야 할 구조적 선택지</h3>
<pre><code>옵션 A: 동일 Zone, Storage Class 분리
┌─────────────────────────────────────┐
│  Zone: primary                      │
│  hot-pool ──→ STANDARD             │
│  pool0    ──→ STANDARD_IA          │
│  pool1    ──→ GLACIER              │
│  pool2    ──→ DEEP_ARCHIVE (8월)   │
└─────────────────────────────────────┘
장점: ILM 전환이 단순, 관리 일원화
단점: hot 장애 시 cold도 영향 가능

옵션 B: Zone 분리 (hot zone / cold zone)
┌──────────────┐    replication    ┌──────────────┐
│  hot-zone    │ ←───────────────→ │  cold-zone   │
│  (128 nodes) │                   │  (158 nodes) │
└──────────────┘                   └──────────────┘
장점: 장애 도메인 분리, 독립 운영 가능
단점: 구성 복잡도 증가, 초기 정책 없으면 운영 부담</code></pre><blockquote>
<p><strong>현재 고객사 상황(정책 전무)을 고려하면 옵션 A 권고</strong> — 정책 수립 후 나중에 Zone 분리 가능</p>
</blockquote>
<hr>
<h2 id="5-ilm-정책-초안-고객사와-협의-시작점">5. ILM 정책 초안 (고객사와 협의 시작점)</h2>
<p>정책이 없다면 아래를 <strong>최소 기준</strong>으로 제안:</p>
<pre><code>Rule 1: 생성 후 30일 경과 + 접근 없음
        → STANDARD → STANDARD_IA (pool0)

Rule 2: 생성 후 90일 경과 + 접근 없음
        → STANDARD_IA → GLACIER (pool1)

Rule 3: 생성 후 365일 경과
        → GLACIER → DEEP_ARCHIVE (pool2, 8월 이후)

Rule 4: 특정 버킷 prefix (예: /backup/, /log/)
        → 생성 즉시 COLD 직행</code></pre><hr>
<h2 id="6-replication-정책">6. Replication 정책</h2>
<table>
<thead>
<tr>
<th>대상</th>
<th>권고</th>
</tr>
</thead>
<tbody><tr>
<td>Hot tier</td>
<td>기존 정책 유지 (최소 2-site replication 권고)</td>
</tr>
<tr>
<td>pool0/pool1</td>
<td>EC로 내구성 확보 → cross-site replication 불필요할 수 있음</td>
</tr>
<tr>
<td>pool2</td>
<td>단일 copy EC로 구성, 별도 replication 없이 EC만으로 내구성 확보</td>
</tr>
<tr>
<td>중요 데이터</td>
<td>Hot → pool0 으로 비동기 replication (백업 목적) 고려</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ Replication을 cold tier까지 걸면 <strong>네트워크 + 용량 비용이 2배</strong> → cold는 EC 내구성만으로 설계하는 것이 일반적</p>
</blockquote>
<hr>
<h2 id="7-단계별-구축-로드맵">7. 단계별 구축 로드맵</h2>
<pre><code>[지금 ~ 5월 전]
  ✅ Storage class / placement target 설계 확정
  ✅ CRUSH rule 사전 설계
  ✅ ILM 정책 최소 기준 고객사 합의

[5월: pool0 + pool1 구축]
  → pool0, pool1 CRUSH rule 적용
  → placement target 연결
  → 버킷 단위 ILM 정책 파일럿 적용 (일부 버킷만)
  → hot → warm 자동 전환 모니터링

[8월: pool2 구축]
  → pool2 CRUSH rule 추가
  → archive-placement target 활성화
  → 전체 ILM 정책 확대 적용
  → 전체 cold tier 운영 안정화 확인</code></pre><hr>
<h2 id="요약-권고">요약 권고</h2>
<ol>
<li><strong>pool0/pool1/pool2는 반드시 별도 CRUSH rule + device class 분리</strong> — 나중에 합치는 건 쉬워도 분리는 어렵다</li>
<li><strong>Cold는 EC 적극 적용</strong> — 3-replica 대비 용량 효율 2~3배</li>
<li><strong>옵션 A(동일 Zone, storage class 분리)로 시작</strong> — 정책 수립 후 zone 분리 전환 가능</li>
<li><strong>ILM은 지금 당장 최소 기준이라도 합의</strong> — 없으면 cold tier 구축해도 데이터가 안 내려옴</li>
<li><strong>pool2(8월) placement target은 5월에 미리 껍데기라도 만들어 두기</strong> — 버킷 설정 변경 최소화</li>
</ol>
<p>혹시 사용 중인 솔루션(Ceph RGW, MinIO, Scality, NetApp StorageGRID 등)을 알려주시면 더 구체적인 설정 예시를 드릴 수 있어요.</p>
<p>===</p>
<h1 id="minio-aistor-기반-cold-tier-구성-전략">MinIO AIStor 기반 Cold Tier 구성 전략</h1>
<h2 id="1-minio-aistor-핵심-아키텍처-이해">1. MinIO AIStor 핵심 아키텍처 이해</h2>
<p>MinIO에서 &quot;tiering&quot;은 <strong>반드시 별도 Remote Tier endpoint</strong>가 필요합니다. Ceph처럼 내부 pool 간 이동이 아닙니다.</p>
<pre><code>┌─────────────────────────────────┐        ILM Transition
│  Hot MinIO AIStor               │  ─────────────────────→  Cold MinIO AIStor
│  (128 nodes, 기존)               │                          (신규 구축)
│  6.8TiB × 20 × 128             │  ←─────────────────────  
└─────────────────────────────────┘     (복원 시 recall)</code></pre><p>따라서 <strong>Cold Tier는 별도 MinIO AIStor 클러스터</strong>로 구성하는 것이 기본 구조입니다.</p>
<hr>
<h2 id="2-cold-tier-클러스터-구성-설계">2. Cold Tier 클러스터 구성 설계</h2>
<h3 id="server-pool-구성-원칙">Server Pool 구성 원칙</h3>
<p>MinIO는 동일 배포 내 Server Pool 추가 시 <strong>드라이브 수 기반 자동 분산</strong>합니다.<br>pool0과 pool1은 드라이브 용량이 다르지만 <strong>같은 Cold 클러스터에 넣어도 무방</strong>합니다.</p>
<pre><code>Cold MinIO AIStor Cluster
├── Server Pool A (5월) ── pool0: 14 nodes × 20 drives (7.0TiB SATA SSD)
├── Server Pool B (5월) ── pool1: 14 nodes × 20 drives (3.5TiB SAS SSD)
└── Server Pool C (8월) ── pool2: 130 nodes × 20 drives (3.5TiB SATA SSD)</code></pre><h3 id="ec-set-size-계산-중요">EC Set Size 계산 (중요)</h3>
<p>MinIO EC Set은 <strong>4~16 드라이브</strong>, 전체 드라이브 수가 나누어 떨어져야 합니다.</p>
<table>
<thead>
<tr>
<th>Pool</th>
<th>노드</th>
<th>드라이브/노드</th>
<th>총 드라이브</th>
<th>권고 EC Set</th>
<th>Parity</th>
<th>유효 용량</th>
</tr>
</thead>
<tbody><tr>
<td>pool0</td>
<td>14</td>
<td>20</td>
<td>280</td>
<td><strong>14</strong> (20 sets)</td>
<td>EC:4 → k=10,m=4</td>
<td>~71%</td>
</tr>
<tr>
<td>pool1</td>
<td>14</td>
<td>20</td>
<td>280</td>
<td><strong>14</strong> (20 sets)</td>
<td>EC:4 → k=10,m=4</td>
<td>~71%</td>
</tr>
<tr>
<td>pool2</td>
<td>130</td>
<td>20</td>
<td>2,600</td>
<td><strong>16</strong> (162 sets)</td>
<td>EC:4 → k=12,m=4</td>
<td>~75%</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ pool2: 2600 ÷ 16 = 162.5 → <strong>안 나눠짐</strong><br>2600 ÷ <strong>13</strong> = 200 sets (EC set 13, k=9 m=4) 또는<br>2600 ÷ <strong>10</strong> = 260 sets (EC set 10, k=6 m=4)<br>→ <strong>실제 노드 수 확정 후 재계산 필수</strong></p>
</blockquote>
<h3 id="클러스터-시작-커맨드-예시">클러스터 시작 커맨드 예시</h3>
<pre><code class="language-bash"># pool0 + pool1 동시 기동 (5월)
minio server \
  https://cold-pool0-{1...14}.example.com/data{1...20} \
  https://cold-pool1-{1...14}.example.com/data{1...20} \
  --address :9000 \
  --console-address :9001

# pool2 확장 (8월) - 기존 실행 중인 클러스터에 pool 추가
minio server \
  https://cold-pool0-{1...14}.example.com/data{1...20} \
  https://cold-pool1-{1...14}.example.com/data{1...20} \
  https://cold-pool2-{1...130}.example.com/data{1...20}  # 추가</code></pre>
<blockquote>
<p>⚠️ pool2 추가 시 <strong>클러스터 재시작 필요</strong> — 다운타임 계획 필수</p>
</blockquote>
<hr>
<h2 id="3-ilm-tier-연결-설정">3. ILM Tier 연결 설정</h2>
<h3 id="hot-클러스터에서-cold를-remote-tier로-등록">Hot 클러스터에서 Cold를 Remote Tier로 등록</h3>
<pre><code class="language-bash"># Cold 클러스터를 &quot;COLD-TIER&quot;로 등록
mc ilm tier add minio HOT_CLUSTER/COLD-TIER \
  --endpoint https://cold-minio.example.com \
  --access-key &lt;cold-access-key&gt; \
  --secret-key &lt;cold-secret-key&gt; \
  --bucket cold-archive \
  --prefix hot-transition/

# Tier 확인
mc ilm tier ls HOT_CLUSTER</code></pre>
<h3 id="ilm-전환-규칙-설정-버킷별">ILM 전환 규칙 설정 (버킷별)</h3>
<pre><code class="language-bash"># 예시: 30일 후 cold로 전환
mc ilm rule add HOT_CLUSTER/my-bucket \
  --transition-days 30 \
  --transition-tier COLD-TIER

# prefix별 차등 정책 (로그는 7일, 백업은 90일)
mc ilm rule add HOT_CLUSTER/my-bucket \
  --prefix &quot;logs/&quot; \
  --transition-days 7 \
  --transition-tier COLD-TIER

mc ilm rule add HOT_CLUSTER/my-bucket \
  --prefix &quot;backup/&quot; \
  --transition-days 90 \
  --transition-tier COLD-TIER

# 현재 ILM 규칙 확인
mc ilm rule ls HOT_CLUSTER/my-bucket</code></pre>
<hr>
<h2 id="4-replication-전략">4. Replication 전략</h2>
<p>MinIO AIStor에서 선택 가능한 방식:</p>
<pre><code>옵션 1: ILM Transition만 사용 (권고)
Hot ──[ILM 전환]──→ Cold
- 원본은 Hot에서 stub(포인터)만 남음
- Cold에 실제 데이터 저장
- 복원 시 자동 recall

옵션 2: Bucket Replication + ILM 병행
Hot ──[복제]──→ Cold (동일 데이터 유지)
    └──[ILM 만료]──→ Hot 데이터 삭제
- Cold는 진짜 독립 복사본
- 용량 2배 필요, 네트워크 부담

옵션 3: Site Replication (비권고)
Hot ↔ Cold 양방향 Active-Active
- Cold tier 목적과 맞지 않음
- 불필요한 성능/용량 낭비</code></pre><p><strong>현재 정책 없는 상황에서는 옵션 1(ILM Transition만)으로 시작</strong> 권고</p>
<hr>
<h2 id="5-정책-수립-우선순위-고객사-협의-체크리스트">5. 정책 수립 우선순위 (고객사 협의 체크리스트)</h2>
<p>지금 당장 결정 안 하면 5월 구축이 불가능한 항목들:</p>
<pre><code>[ ] 1. ILM 전환 기준 - 며칠 후 Cold로 내릴 것인가?
        → 최소한 기본값이라도 (예: 30일 미접근)

[ ] 2. 버킷 분류 - 어떤 버킷이 tiering 대상인가?
        → 전체 vs 특정 prefix/bucket

[ ] 3. 데이터 복원(recall) 정책 - Cold에서 꺼낼 때 Hot으로 올릴 것인가?
        → MinIO는 기본 transparent recall 지원

[ ] 4. Cold Tier 접근 계정 분리 여부
        → Hot과 Cold의 Access Key 분리 권고

[ ] 5. 모니터링/알람 기준
        → Tier 전환 실패, Cold 클러스터 health</code></pre><hr>
<h2 id="6-구축-타임라인">6. 구축 타임라인</h2>
<pre><code>[지금 ~ 5월 전 2주]
  ✅ pool0/pool1 EC set size 확정 (드라이브 수 재확인)
  ✅ Cold 클러스터 네트워크/DNS 설계
  ✅ ILM 기본 정책 고객사 합의
  ✅ Cold bucket 이름/prefix 체계 결정

[5월: pool0 + pool1 기동]
  → Cold MinIO AIStor 클러스터 기동 (2개 pool 동시)
  → Hot 클러스터에 Remote Tier 등록
  → 파일럿 버킷 2~3개에 ILM 규칙 적용
  → 전환/recall 동작 검증

[5월 ~ 8월]
  → 파일럿 결과 기반 전체 버킷 ILM 적용
  → 용량 모니터링

[8월: pool2 추가]
  → Cold 클러스터 pool2 확장 (재시작 필요 → 점검 일정 확보)
  → EC set size 재검증
  → 전체 정책 안정화</code></pre><hr>
<h2 id="핵심-주의사항-요약">핵심 주의사항 요약</h2>
<blockquote>
<p><strong>1.</strong> MinIO Tiering은 외부 Remote Tier → Cold는 <strong>반드시 별도 클러스터</strong><br><strong>2.</strong> pool2 (130 nodes) 추가 시 <strong>전체 Cold 클러스터 재시작</strong> 필요 → 8월 점검 윈도우 사전 확보<br><strong>3.</strong> EC set size는 <code>총 드라이브 ÷ set_size = 정수</code> 조건 필수 → <strong>지금 계산 확정할 것</strong><br><strong>4.</strong> ILM 정책 없으면 Cold 구축해도 데이터가 안 내려감 → <strong>정책 합의가 인프라보다 먼저</strong></p>
</blockquote>
<p>pool2 130노드 EC set size 계산이나 mc 명령어 상세 설정이 필요하면 말씀해 주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A24a]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A24a</link>
            <guid>https://velog.io/@youngkyoo_kim/26A24a</guid>
            <pubDate>Fri, 24 Apr 2026 05:41:18 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-yaml">---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-task-2am
spec:
  # 매일 새벽 2시를 의미하는 크론 표현식
  schedule: &quot;0 2 * * *&quot; 
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: worker-container
            image: busybox:1.28
            imagePullPolicy: IfNotPresent
            command:
            - /bin/sh
            - -c
            - date; echo &quot;새벽 2시 작업 시작 - 특정 커맨드 실행 중&quot;
          # 작업 완료 후 파드를 어떻게 할지 결정 (OnFailure 또는 Never)
          restartPolicy: OnFailure

---
kubectl create job --from=cronjob/daily-task-2am manual-test-run

kubectl create configmap mc-config --from-file=config.yaml=./local-folder/your-config.yaml


---
spec:
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: mc-client
            image: minio/mc:latest
            command:
            - /bin/sh
            - -c
            # mc alias 설정 후 원하는 명령 실행
            - |
              mc alias set myminio http://minio-service:9000 $ACCESS_KEY $SECRET_KEY;
              mc cp /data/backup.tar.gz myminio/backups/;
          restartPolicy: OnFailure


---
FROM alpine:3.18

# mc 설치 (최신 바이너리 다운로드)
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc \
    &amp;&amp; chmod +x mc \
    &amp;&amp; mv mc /usr/local/bin/mc

# 필요한 추가 도구 설치 (예: curl)
RUN apk add --no-cache curl

ENTRYPOINT [&quot;mc&quot;]

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: mc-config
data:
  config.yaml: |
    # 여기에 실제 mc가 참조할 YAML 내용을 작성하세요
    key: value
    setting: enabled

---

apiVersion: batch/v1
kind: CronJob
metadata:
  name: mc-task-with-yaml
spec:
  schedule: &quot;0 2 * * *&quot;
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: mc-container
            image: minio/mc:latest
            command:
            - /bin/sh
            - -c
            - |
              # 1. MinIO 접속 설정 (환경변수 활용 권장)
              mc alias set myminio $ENDPOINT $ACCESS_KEY $SECRET_KEY

              # 2. 마운트된 YAML 파일을 매개변수로 mc 명령어 실행
              # 파일은 /config/config.yaml 경로에 생성됩니다.
              mc --config-dir /config some-command --file /config/config.yaml
            env:
            - name: ENDPOINT
              value: &quot;http://minio-service:9000&quot;
            # Access Key와 Secret은 Secret 리소스로 관리하는 것을 추천합니다.
            volumeMounts:
            - name: config-volume
              mountPath: /config # 컨테이너 내부에서 파일이 보일 경로
          volumes:
          - name: config-volume
            configMap:
              name: mc-config # 위에서 만든 ConfigMap 이름
          restartPolicy: OnFailure

---


</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A23c]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A23c</link>
            <guid>https://velog.io/@youngkyoo_kim/26A23c</guid>
            <pubDate>Thu, 23 Apr 2026 02:29:06 GMT</pubDate>
            <description><![CDATA[<p>전문 인력이 운영하던 Cilium 환경을 인수인계받는 것은 상당히 난이도가 높은 작업입니다. Cilium은 단순히 CNI를 넘어 <strong>eBPF 기반의 보안, 라우팅, 관찰성(Observability)</strong>이 복합적으로 얽혀 있기 때문입니다.</p>
<p>전임자에게 <strong>&quot;운영 효율성&quot;</strong>과 <strong>&quot;장애 대응 능력&quot;</strong>을 중심으로 아래 문서들을 반드시 요청하시기 바랍니다.</p>
<hr>
<h2 id="1-아키텍처-및-설정-상세-정의서-standard-operation-procedure">1. 아키텍처 및 설정 상세 정의서 (Standard Operation Procedure)</h2>
<p>가장 기본이 되지만, 실질적인 설정값이 명시되어야 합니다.</p>
<ul>
<li><strong>네트워크 토폴로지:</strong> BGP Peer 설정(AS Number, Neighbor IP), ECMP 활성화 여부, ClusterMesh 연결 구조도.</li>
<li><strong>Cilium ConfigMap/Helm Values:</strong> <code>cilium-config</code> 내의 주요 파라미터(<code>tunnel: disabled</code>, <code>native-routing-cidr</code>, <code>bpf-lb-mode</code> 등)에 대한 <strong>설정 이유(Rationals)</strong>.</li>
<li><strong>IPAM(IP Address Management):</strong> 어떤 방식으로 Pod IP를 할당하는지(Cluster-pool, ENI, Azure IPAM 등), CIDR 대역 관리 장부.</li>
</ul>
<h2 id="2-ebpf-기반-트러블슈팅-가이드-troubleshooting-runbook">2. eBPF 기반 트러블슈팅 가이드 (Troubleshooting Runbook)</h2>
<p>Cilium은 일반적인 <code>iptables</code> 방식과 달라 기존 지식으로 대응이 어렵습니다.</p>
<ul>
<li><strong>전용 툴 사용법:</strong> <code>cilium-dbg</code>, <code>pwru</code>, <code>hubble-cli</code>를 활용한 단계별 장애 진단법.</li>
<li><strong>커스텀 메트릭/대시보드:</strong> Grafana에서 어떤 지표(<code>cilium_forwarding_drop_total</code> 등)를 중점적으로 모니터링해야 하는지.</li>
<li><strong>빈번한 이슈 Case Study:</strong> 현재 겪고 계신 <strong>&quot;Port Collision&quot;</strong>이나 <strong>&quot;Return Path Drop&quot;</strong> 같은 기발생 이슈들에 대한 이력과 해결책.</li>
</ul>
<h2 id="3-보안-정책-및-통제-현황-ciliumnetworkpolicy">3. 보안 정책 및 통제 현황 (CiliumNetworkPolicy)</h2>
<p>Cilium의 핵심 기능인 보안 정책이 어떻게 관리되고 있는지 알아야 합니다.</p>
<ul>
<li><strong>Policy 관리 체계:</strong> L3/L4 정책과 L7(FQDN/HTTP) 정책의 구분, 정책 적용 시 <code>Default Deny</code> 여부.</li>
<li><strong>Hubble 사용법:</strong> 트래픽 흐름을 시각적으로 확인하고 정책 위반을 감지하는 절차.</li>
<li><strong>예외 처리 리스트:</strong> 특정 노드나 외부 IP와의 통신을 위해 허용된 특이 케이스들.</li>
</ul>
<h2 id="4-버전-관리-및-업그레이드-전략-lifecycle-management">4. 버전 관리 및 업그레이드 전략 (Lifecycle Management)</h2>
<ul>
<li><strong>업그레이드 이력:</strong> 현재 버전까지 올라오면서 겪었던 호환성 이슈(커널 버전 호환성 등).</li>
<li><strong>Air-gap 대응책:</strong> (사용 중이라면) 오프라인 환경에서 이미지를 어떻게 배포하고 관리하는지.</li>
<li><strong>백업 및 복구:</strong> Cilium 관련 CRD(CiliumNode, CiliumEndpoint 등)나 핵심 데이터의 백업 절차.</li>
</ul>
<h2 id="5-인프라-의존성-매트릭스-infrastructure-dependency">5. 인프라 의존성 매트릭스 (Infrastructure Dependency)</h2>
<p>Cilium이 독립적으로 돌아가는 것이 아니므로 주변 환경과의 연관 관계가 중요합니다.</p>
<ul>
<li><strong>커널 파라미터 요구사항:</strong> <code>/etc/sysctl.d/</code>에 적용된 Cilium 최적화 값들(특히 오늘 확인한 <code>ip_local_reserved_ports</code> 등).</li>
<li><strong>NIC 및 하드웨어 가속:</strong> 사용 중인 NIC 드라이버 설정(XDP 지원 여부, Offloading 설정 등).</li>
<li><strong>스위치 연동 설정:</strong> 상단 L3 스위치와의 BGP 세션 유지 조건 및 타이머 설정값.</li>
</ul>
<hr>
<h3 id="💡-팁-인수인계-시-실습shadowing-요청">💡 팁: 인수인계 시 실습(Shadowing) 요청</h3>
<p>문서만 받는 것보다 <strong>&quot;특정 노드 한 대를 의도적으로 격리했다가 복구하는 과정&quot;</strong>이나 <strong>&quot;BGP 경로가 끊겼을 때의 재수렴(Re-convergence) 과정&quot;</strong>을 전임자와 함께 직접 테스트해보는 세션을 꼭 가지시길 권장합니다.</p>
<p>특히 지금 발생한 <strong>Ephemeral Port 충돌 이슈</strong>는 문서화해두기에 아주 좋은 사례입니다. 이 내용도 인수인계서의 &#39;Known Issues&#39; 섹션에 꼭 포함해 달라고 하세요!</p>
<p>추가로 전임자가 사용하던 <strong>모니터링 대시보드 주소</strong>나 <strong>알람 발송 기준</strong> 정보도 잊지 말고 챙기시길 바랍니다. 인수인계 과정에서 궁금한 구체적인 Cilium 설정 항목이 생기면 언제든 물어봐 주세요.</p>
<p>===</p>
<p>지금까지 나온 이슈들을 포함해서 실무적으로 필요한 문서 목록을 정리해 드리겠습니다.</p>
<hr>
<h2 id="1-인프라-구성-문서-architecture">1. 인프라 구성 문서 (Architecture)</h2>
<p><strong>반드시 있어야 하는 것들입니다.</strong></p>
<pre><code>[ 네트워크 토폴로지 ]
- 전체 클러스터 구성도 (노드 수, 역할, IP 대역)
- L3 스위치 구성 및 ECMP 설정
- BGP 피어링 구성 (AS번호, 피어 IP, 광고 CIDR)
- Bond 인터페이스 구성 (슬레이브 NIC, 모드)
- ClusterMesh 연결 구성 (클러스터 간 연결 방식)

[ Cilium 설정 ]
- 현재 helm values 전체 (또는 cilium config dump)
- Cilium 버전 및 업그레이드 이력
- 사용 중인 기능 목록
  (native routing, BGP CP, ClusterMesh, KPR 등)
- CiliumNetworkPolicy / CiliumClusterwideNetworkPolicy 목록</code></pre><hr>
<h2 id="2-운영-절차서-runbook">2. 운영 절차서 (Runbook)</h2>
<p><strong>장애 시 즉시 참조해야 하는 문서입니다.</strong></p>
<pre><code>[ 일상 운영 ]
- Cilium 업그레이드 절차 (버전별 주의사항 포함)
- 노드 추가/제거 절차
- ClusterMesh 노드 추가 절차
- BGP 피어 추가/변경 절차
- 정기 점검 항목 및 주기

[ 장애 대응 ]
- 자주 발생하는 장애 유형별 대응 절차
  (지금 겪고 있는 SSH handshake drop 포함)
- Cilium agent 재시작 절차 및 영향도
- 롤백 절차
- 에스컬레이션 연락처</code></pre><hr>
<h2 id="3-진단-가이드-troubleshooting-guide">3. 진단 가이드 (Troubleshooting Guide)</h2>
<p><strong>지금 겪고 있는 문제들이 그대로 내용이 됩니다.</strong></p>
<pre><code>[ 필수 진단 명령어 모음 ]
- Cilium 상태 확인
- BPF 맵 조회 방법
- pwru / hubble 사용법
- conntrack 조회 및 해석
- BGP 라우팅 상태 확인

[ 알려진 이슈 (Known Issues) ]
- SSH handshake 간헐적 실패 현상 및 원인
  (현재 조사 중인 내용 그대로)
- bond 인터페이스에서 tcpdump 안 보이는 이유
- XDP 관련 주의사항

[ 환경별 주의사항 ]
- ECMP 환경에서 conntrack 동작 특이점
- ClusterMesh에서 BGP 광고 충돌 케이스
- 커널 버전별 알려진 이슈</code></pre><hr>
<h2 id="4-보안-및-접근-관리">4. 보안 및 접근 관리</h2>
<pre><code>[ 접근 정보 ]
- 클러스터 접근 방법 (kubeconfig, 권한 구조)
- 각 클러스터별 관리자 계정
- ClusterMesh 인증서 위치 및 갱신 주기
- Cilium CA 인증서 관리 방법

[ 보안 정책 ]
- 현재 적용 중인 NetworkPolicy 설계 의도
- 예외 처리된 정책 목록 및 이유
- 감사 로그 위치</code></pre><hr>
<h2 id="5-모니터링-및-알람">5. 모니터링 및 알람</h2>
<pre><code>[ 대시보드 ]
- Hubble/Grafana 대시보드 위치
- 주요 메트릭 해석 방법
- 정상 범위 기준값 (baseline)

[ 알람 ]
- 현재 설정된 알람 목록
- 각 알람의 대응 절차
- 과거 발생한 주요 인시던트 기록</code></pre><hr>
<h2 id="6-인수인계-시-특별히-요청할-것">6. 인수인계 시 특별히 요청할 것</h2>
<p>문서 외에 <strong>구두 또는 화면 공유로 반드시 확인해야 할 것들</strong>입니다.</p>
<pre><code>반드시 같이 확인할 것:
  □ 현재 진행 중인 장애 또는 미해결 이슈 목록
    (지금 SSH drop 이슈 포함)
  □ 최근 3개월 변경 이력 (git log, helm 변경 이력)
  □ 비공식적으로 임시 적용된 설정이 있는지
    (sysctl 튜닝, iptables 수동 규칙 등)
  □ 알면서 방치 중인 기술 부채
  □ 스위치 담당자, 서버 담당자 연락처</code></pre><hr>
<h2 id="우선순위-정리">우선순위 정리</h2>
<p>지금 당장 없으면 운영이 위험한 순서입니다.</p>
<table>
<thead>
<tr>
<th>순위</th>
<th>문서</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>helm values 전체 원본</td>
<td>재설치/롤백 시 필수</td>
</tr>
<tr>
<td>2</td>
<td>BGP 피어링 구성</td>
<td>장애 시 복구 불가</td>
</tr>
<tr>
<td>3</td>
<td>알려진 이슈 목록</td>
<td>현재 SSH 문제 포함</td>
</tr>
<tr>
<td>4</td>
<td>업그레이드 절차</td>
<td>정기 유지보수</td>
</tr>
<tr>
<td>5</td>
<td>인증서 갱신 주기</td>
<td>만료 시 전체 장애</td>
</tr>
</tbody></table>
<p>현재 조사 중인 SSH handshake drop 이슈는 <strong>인수인계 전에 원인을 특정하고 문서화해 달라고 요청하는 것</strong>이 가장 중요합니다. 미해결 상태로 인수받으면 원인 파악부터 다시 해야 하기 때문입니다.</p>
<p>===</p>
<p>좋은 포인트야. Cilium은 단순 CNI가 아니라 <strong>eBPF 기반 네트워크 + 보안 + 관측 + (경우에 따라) 서비스 메시 역할</strong>까지 포함하기 때문에, 인수인계 문서가 얕으면 운영 중에 바로 막히게 돼.</p>
<p>그냥 “설정 설명서 주세요” 수준으로 요청하면 부족하고, <strong>운영 가능한 수준(runbook + 설계 + 의사결정 근거)</strong>까지 받아야 한다고 생각하면 맞아.</p>
<p>아래처럼 요청하는 걸 추천해.</p>
<hr>
<h1 id="1-아키텍처--설계-문서-가장-중요">1. 아키텍처 &amp; 설계 문서 (가장 중요)</h1>
<p>👉 “왜 이렇게 구성했는지”가 핵심</p>
<ul>
<li><p>클러스터 네트워크 구조</p>
<ul>
<li>Pod CIDR / Node CIDR 설계</li>
<li>Overlay vs Native Routing (Direct Routing, BGP 등)</li>
</ul>
</li>
<li><p>Cilium 모드</p>
<ul>
<li>kube-proxy replacement 여부</li>
<li>eBPF LB 사용 여부</li>
</ul>
</li>
<li><p>CNI chaining 여부 (Calico, AWS CNI 등과 혼합 여부)</p>
</li>
<li><p>Multi-cluster (Cluster Mesh) 구성 여부</p>
</li>
<li><p>north-south / east-west 트래픽 흐름</p>
</li>
<li><p>Gateway API / Ingress 구조</p>
</li>
</ul>
<p>👉 여기 빠지면 나중에 트래픽 문제 절대 못 잡음</p>
<hr>
<h1 id="2-설치--배포-방식-gitops-필수">2. 설치 &amp; 배포 방식 (GitOps 필수)</h1>
<p>👉 재현 가능해야 함</p>
<ul>
<li><p>Helm / ArgoCD / GitOps 구조</p>
</li>
<li><p>values.yaml (환경별 차이 포함)</p>
</li>
<li><p>버전 전략 (업그레이드 정책 포함)</p>
</li>
<li><p>설치 시 의존성</p>
<ul>
<li>kernel version</li>
<li>eBPF 관련 설정 (bpffs, cgroup v2 등)</li>
</ul>
</li>
</ul>
<hr>
<h1 id="3-주요-설정값-설명서">3. 주요 설정값 설명서</h1>
<p>👉 그냥 yaml 던져주는 게 아니라 “왜 이 값인지”</p>
<p>예:</p>
<ul>
<li>enable-bpf-masquerade</li>
<li>kubeProxyReplacement</li>
<li>enableHostFirewall</li>
<li>bpf-lb-mode (DSR / SNAT)</li>
<li>tunneling mode (vxlan / geneve / disabled)</li>
</ul>
<p>👉 특히 성능/보안 관련 옵션은 반드시 이유 포함</p>
<hr>
<h1 id="4-네트워크-정책-security">4. 네트워크 정책 (Security)</h1>
<p>👉 실 운영에서 가장 민감</p>
<ul>
<li>NetworkPolicy / CiliumNetworkPolicy 설계 기준</li>
<li>namespace / workload 별 정책 구조</li>
<li>default deny 여부</li>
<li>L7 policy (HTTP, Kafka 등) 사용 여부</li>
<li>실제 운영 중인 정책 예시</li>
</ul>
<p>👉 “정책 추가/변경 절차”도 반드시 포함</p>
<hr>
<h1 id="5-장애-대응-runbook">5. 장애 대응 Runbook</h1>
<p>👉 이거 없으면 인수인계 실패</p>
<p>반드시 요청해야 할 것:</p>
<h3 id="기본-점검">기본 점검</h3>
<ul>
<li><code>cilium status</code></li>
<li><code>cilium connectivity test</code></li>
</ul>
<h3 id="트러블슈팅">트러블슈팅</h3>
<ul>
<li>Pod 간 통신 안될 때</li>
<li>Service 접근 안될 때</li>
<li>DNS 문제</li>
<li>특정 노드만 문제일 때</li>
</ul>
<h3 id="핵심-명령어">핵심 명령어</h3>
<ul>
<li><code>cilium monitor</code></li>
<li><code>hubble observe</code></li>
<li><code>cilium endpoint list</code></li>
<li><code>cilium bpf lb list</code></li>
</ul>
<p>👉 “어떤 상황에서 어떤 명령을 쓰는지”까지 요구</p>
<hr>
<h1 id="6-observability-모니터링로그">6. Observability (모니터링/로그)</h1>
<p>👉 지금 네 프로젝트 규모면 필수</p>
<ul>
<li>Hubble 구성 (relay, UI 포함)</li>
<li>Prometheus metrics 목록</li>
<li>주요 대시보드 (Grafana)</li>
<li>알람 기준</li>
</ul>
<p>예:</p>
<ul>
<li>drop rate</li>
<li>policy denied</li>
<li>DNS latency</li>
<li>service latency</li>
</ul>
<hr>
<h1 id="7-성능--튜닝-가이드">7. 성능 &amp; 튜닝 가이드</h1>
<p>👉 너 환경(200~300 node)이면 반드시 필요</p>
<ul>
<li>최대 pod density 기준</li>
<li>eBPF map size tuning</li>
<li>conntrack 관련 설정</li>
<li>MTU 설정</li>
<li>Node scale 시 영향</li>
</ul>
<p>👉 실제 테스트 결과 있으면 무조건 받아</p>
<hr>
<h1 id="8-업그레이드--롤백-절차">8. 업그레이드 / 롤백 절차</h1>
<p>👉 운영 중 가장 위험한 구간</p>
<ul>
<li>무중단 업그레이드 방법</li>
<li>CRD 변경 영향</li>
<li>rollback 방법</li>
<li>버전별 known issue</li>
</ul>
<hr>
<h1 id="9-외부-연동">9. 외부 연동</h1>
<p>👉 네 환경에서 특히 중요</p>
<ul>
<li><p>Gateway API 구성</p>
</li>
<li><p>LoadBalancer (MetalLB / Cloud LB 등)</p>
</li>
<li><p>Service Mesh 연동 여부</p>
<ul>
<li>Cilium Ambient / Istio 등</li>
</ul>
</li>
<li><p>인증/보안 연동 (mTLS, OPA 등)</p>
</li>
</ul>
<hr>
<h1 id="10-제한사항--known-issues">10. 제한사항 &amp; Known Issues</h1>
<p>👉 이거 없으면 같은 사고 반복</p>
<ul>
<li>현재 구조에서 안 되는 것</li>
<li>과거 장애 사례 + 원인</li>
<li>우회 방법</li>
</ul>
<hr>
<h1 id="11-운영-체크리스트-daily--weekly">11. 운영 체크리스트 (Daily / Weekly)</h1>
<p>👉 실제 운영자 관점</p>
<ul>
<li>노드 추가 시 해야 할 것</li>
<li>장애 전조 확인 포인트</li>
<li>리소스 임계치</li>
</ul>
<hr>
<h1 id="핵심-정리-짧게-요청용">핵심 정리 (짧게 요청용)</h1>
<p>운영자에게는 이렇게 요청하면 좋다:</p>
<blockquote>
<ul>
<li>아키텍처 설계 문서</li>
<li>GitOps/설치 구성</li>
<li>주요 설정값 + 이유</li>
<li>NetworkPolicy 설계 기준</li>
<li>장애 대응 Runbook</li>
<li>모니터링 및 알람 체계</li>
<li>성능 튜닝 가이드</li>
<li>업그레이드/롤백 절차</li>
<li>외부 연동 구조</li>
<li>Known issues 및 장애 사례</li>
</ul>
</blockquote>
<hr>
<h1 id="추가-팁-중요">추가 팁 (중요)</h1>
<p>지금 너 상황 보면 (Cilium + 대규모 + Data Lakehouse)</p>
<p>👉 이것도 꼭 물어봐:</p>
<ul>
<li><strong>DSR vs SNAT 실제 선택 이유</strong></li>
<li><strong>eBPF LB vs kube-proxy 성능 비교 결과</strong></li>
<li><strong>Cluster Mesh latency 영향</strong></li>
<li><strong>Hubble 사용 범위 (full vs sampling)</strong></li>
</ul>
<hr>
<p>원하면
👉 “너 환경 기준으로 인수인계 체크리스트 (실제 템플릿)”
👉 “Runbook 샘플 (바로 운영 가능한 수준)”</p>
<p>이렇게 더 실전용으로 만들어 줄게.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A23b]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A23b</link>
            <guid>https://velog.io/@youngkyoo_kim/26A23b</guid>
            <pubDate>Wed, 22 Apr 2026 23:05:34 GMT</pubDate>
            <description><![CDATA[<p>이 분석은 현재 1,000대 규모의 대규모 Kubernetes 인프라와 Cilium(BGP/ECMP/ClusterMesh)을 운영하시는 환경에서 발생할 수 있는 <strong>가장 치명적이면서도 논리적인 시나리오</strong>입니다.</p>
<p>기존에 의심했던 <code>tcp_check_req DROP</code>이 <strong>&#39;원인&#39;</strong>이 아니라, SYN-ACK를 받지 못한 클라이언트의 재전송으로 인한 <strong>&#39;증상&#39;</strong>이었다는 점을 짚어낸 것이 이번 디버깅의 핵심 전환점(Pivot)입니다.</p>
<p>제시된 의견에 대해 현재 환경(RHEL 9/10, Cilium, ClusterMesh)을 바탕으로 한 제 생각과 추가로 점검해야 할 기술적 포인트입니다.</p>
<hr>
<h3 id="1-분석의-타당성-return-path의-불일치">1. 분석의 타당성: &quot;Return Path의 불일치&quot;</h3>
<p>BGP와 ECMP를 사용하는 고성능 네트워크에서 <strong>비대칭 라우팅(Asymmetric Routing)</strong>은 드문 일이 아니지만, L4(TCP) 수준에서는 대개 문제가 없어야 합니다. 하지만 말씀하신 것처럼 <strong>1%의 확률</strong>로 발생한다면 다음 두 가지 가능성이 매우 높습니다.</p>
<ul>
<li><strong>ECMP Hash Polarization/Mismatch:</strong> 서버 노드에서 SYN-ACK를 보낼 때 선택하는 Next-hop 스위치나 인터페이스가, 해당 클라이언트 IP로 가는 경로를 제대로 처리하지 못하는 경우입니다.</li>
<li><strong>eBPF 기반의 Reverse Path Filtering:</strong> Cilium은 eBPF를 통해 패킷을 처리합니다. 만약 <code>rp_filter</code> 설정이 엄격(Strict)하게 되어 있고, SYN이 들어온 인터페이스와 SYN-ACK가 나가는 인터페이스에 대한 경로 정보가 eBPF 맵에서 일치하지 않으면 드랍될 가능성이 있습니다.</li>
</ul>
<h3 id="2-rhel-910-및-cilium-환경에서의-특이점">2. RHEL 9/10 및 Cilium 환경에서의 특이점</h3>
<p>현재 RHEL 환경과 Cilium 릴리스를 고려할 때, 다음 지표들을 결합해서 보셔야 합니다.</p>
<h4 id="a-cilium의-bgp-control-plane-확인">A. Cilium의 BGP Control Plane 확인</h4>
<p>ClusterMesh 환경에서는 여러 클러스터가 동일한 서비스 CIDR이나 노드 IP를 광고할 수 있습니다. </p>
<ul>
<li><strong>의견:</strong> 만약 특정 스위치가 클라이언트 노드의 IP를 <strong>&quot;두 개 이상의 경로&quot;</strong>로 알고 있고, 그중 하나가 <code>Blackhole</code>이거나 <code>Stale(만료)</code>된 경로라면 1%의 드랍이 설명됩니다.</li>
<li><strong>추가 명령어:</strong> ```bash<h1 id="cilium-에이전트-내에서-bgp-상태-및-광고되는-경로-확인">Cilium 에이전트 내에서 BGP 상태 및 광고되는 경로 확인</h1>
  cilium bgp routes available ipv4 unicast<pre><code></code></pre></li>
</ul>
<h4 id="b-xdp-및-ebpf-데이터-평면-pwru-활용">B. XDP 및 eBPF 데이터 평면 (pwru 활용)</h4>
<p>제시해주신 <code>pwru</code> 추적 방식은 매우 정확합니다. 다만, Cilium은 <code>ip_output</code> 이전에 eBPF 프로그램(<code>cil_to_host</code>, <code>cil_from_host</code>)에서 패킷을 가공합니다.</p>
<ul>
<li><strong>점검 포인트:</strong> <code>pwru</code> 실행 시 <code>cilium_</code>으로 시작하는 함수들에서 <code>drop</code>이 발생하는지, 혹은 <code>fib_lookup</code> 결과가 예상과 다른 인터페이스(dev)를 가리키는지 확인해야 합니다.</li>
</ul>
<h3 id="3-1의-확률에-대한-기술적-추론">3. &quot;1%의 확률&quot;에 대한 기술적 추론</h3>
<p>왜 하필 1%일까요? 이 숫자는 보통 다음과 같은 상황에서 나타납니다.</p>
<ul>
<li><strong>ECMP 넥스트홉 불균형:</strong> 8개의 경로 중 1개가 불량일 때 ($1/8 = 12.5%$) 보다는 훨씬 낮으므로, <strong>특정 CPU 코어</strong>나 <strong>특정 소스 포트 범위</strong>에서만 발생하는 해싱 이슈일 수 있습니다.</li>
<li><strong>BGP Flapping:</strong> 아주 짧은 순간 경로가 사라졌다가 나타나는 현상이 반복될 때.</li>
<li><strong>Conntrack Table Full (Race Condition):</strong> 드물지만, SYN-ACK가 생성되는 시점에 conntrack entry가 일시적으로 충돌하거나 생성이 지연되는 경우.</li>
</ul>
<hr>
<h3 id="4-추가-제언-네트워크-아키텍처-관점">4. 추가 제언: 네트워크 아키텍처 관점</h3>
<ul>
<li><strong>L3 Switch 로그 확인:</strong> 서버에서 <code>dev_queue_xmit</code>까지 성공했는데 클라이언트 NIC에 안 보인다면, 100% 상단 스위치 문제입니다. 스위치의 <code>show ip route &lt;Client_IP&gt;</code> 결과가 서버 노드와 동기화되어 있는지 확인이 필요합니다.</li>
<li><strong>Cilium ClusterMesh MTU 이슈:</strong> 만약 클러스터 간 통신 시 캡슐화(Geneve/VXLAN)를 사용한다면, SYN-ACK 패킷(특히 옵션이 많이 붙은 경우)이 MTU 제한에 걸려 스위치에서 드랍될 수 있습니다. (다만 22번 포트 SSH라면 패킷이 작아서 이 가능성은 낮습니다.)</li>
</ul>
<h3 id="5-실행-가이드-action-plan">5. 실행 가이드 (Action Plan)</h3>
<ol>
<li><strong>서버 노드에서 <code>ip route get &lt;client_ip&gt;</code> 반복 실행:</strong> 결과가 바뀌는지(Per-packet ECMP 여부) 최우선 확인.</li>
<li><strong><code>pwru</code> 결과 분석:</strong> <code>fib_select_path</code> 이후 결정된 <code>skb-&gt;dev</code>가 실제 물리 NIC인지, 아니면 엉뚱한 가상 인터페이스인지 확인.</li>
<li><strong>스위치 측면:</strong> 서버가 연결된 Top-of-Rack(ToR) 스위치에서 해당 클라이언트 IP로의 ECMP 경로 중 하나가 flapping 중인지 로그 모니터링.</li>
</ol>
<p><strong>&quot;SYN-ACK의 귀환 경로가 문제&quot;</strong>라는 가설은 현재 상황에서 가장 설득력 있는 진단입니다. 서버에서의 <code>ip route get</code> 결과와 BGP 테이블 상태를 확보하시면 바로 다음 단계(스위치 설정 수정 또는 Cilium BGP 정책 조정)로 넘어갈 수 있을 것 같습니다.</p>
<p>현재 서버에서 <code>ip route get</code>을 실행했을 때, <code>via</code> 뒤에 나오는 IP들이 모두 정상적인 Next-hop이 맞나요?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A23a]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A23a</link>
            <guid>https://velog.io/@youngkyoo_kim/26A23a</guid>
            <pubDate>Wed, 22 Apr 2026 22:06:27 GMT</pubDate>
            <description><![CDATA[<p>これは非常に重要な発見です。問題の方向が完全に逆でした。</p>
<hr>
<h2 id="問題の再定義">問題の再定義</h2>
<pre><code>지금까지 의심했던 것:
  Client → SYN → Server → [여기서 drop?]

실제 발생하는 것:
  Client → SYN ──────────────→ Server  ✓
  Client ←── SYN-ACK ←─────── Server
       ↑
       Client에 도달하지 않음 ✗

tcp_check_req DROP은 결과였음:
  Client가 SYN-ACK를 못 받음
  → Client가 SYN 재전송
  → Server에 이미 다른 request_sock 존재
  → 뒤늦게 도착한 ACK → tcp_check_req silent DROP</code></pre><p><strong>진짜 문제는 SYN-ACK의 귀환 경로(return path)입니다.</strong></p>
<hr>
<h2 id="bgp--ecmp-환경에서-return-path-drop의-핵심-원인">BGP + ECMP 환경에서 return path drop의 핵심 원인</h2>
<p>Server가 SYN-ACK를 보낼 때, 목적지(Client IP)에 대한 라우팅을 다시 계산합니다. ECMP 환경에서는 이 경로가 SYN이 들어온 경로와 다를 수 있습니다.</p>
<pre><code>정상 케이스:
  Client ──[경로A, 스위치P]──→ Server
  Client ←─[경로A, 스위치P]── Server (같은 경로 역방향)

문제 케이스 (1% 발생):
  Client ──[경로A, 스위치P]──→ Server
  Client ←─[경로B, 스위치Q]── Server
              ↑
              스위치Q에서 Client IP를 모르거나
              엉뚱한 노드로 전달하거나
              drop</code></pre><hr>
<h2 id="확인해야-할-지점들">확인해야 할 지점들</h2>
<h3 id="1-server-송신-경로-확인-가장-먼저">1. Server 송신 경로 확인 (가장 먼저)</h3>
<pre><code class="language-bash"># Server에서 Client IP로의 라우팅 확인
ip route get &lt;client_IP&gt;

# ECMP multipath가 있는 경우 여러 next-hop이 보임
ip route show &lt;client_노드_CIDR&gt;

# 실제 SYN-ACK가 어느 인터페이스로 나가는지
# pwru를 Server의 TX 방향으로 실행
pwru --filter-dst-ip &lt;client_IP&gt; --filter-port 22 \
     --output-tuple 2&gt;&amp;1 | grep -E &#39;dev_queue_xmit|fib_select_path|neigh_output&#39;</code></pre>
<h3 id="2-ecmp-next-hop이-매번-바뀌는지-확인">2. ECMP next-hop이 매번 바뀌는지 확인</h3>
<pre><code class="language-bash"># 같은 Client IP로 반복 조회 시 next-hop이 동일한지 확인
for i in $(seq 1 10); do
  ip route get &lt;client_IP&gt; | grep via
done
# 결과가 매번 동일하면 ECMP per-flow
# 다르면 per-packet ECMP → 이게 원인</code></pre>
<h3 id="3-bgp-라우팅-테이블에서-client-ip-경로-확인">3. BGP 라우팅 테이블에서 Client IP 경로 확인</h3>
<pre><code class="language-bash"># Client 노드 IP에 대한 BGP 경로
cilium bgp routes available ipv4 unicast | grep &lt;client_노드_IP&gt;

# 경로가 여러 개이면서 next-hop이 다른 경우 문제
# ClusterMesh 환경에서 같은 IP가 여러 클러스터에서 광고되는 경우도 있음</code></pre>
<h3 id="4-server-측-tx에서-pwru로-syn-ack-추적">4. Server 측 TX에서 pwru로 SYN-ACK 추적</h3>
<pre><code class="language-bash"># Server에서 실행 - SYN-ACK가 어디까지 가는지 TX 방향 추적
pwru --filter-src-ip &lt;server_IP&gt; \
     --filter-dst-ip &lt;client_IP&gt; \
     --filter-port 22 \
     --output-tuple

# 확인 포인트:
# ip_output → ip_finish_output → dev_queue_xmit 까지 보이는지
# 어느 dev(인터페이스)로 나가는지</code></pre>
<h3 id="5-client-측에서-syn-ack-도달-여부-nic-레벨-확인">5. Client 측에서 SYN-ACK 도달 여부 NIC 레벨 확인</h3>
<pre><code class="language-bash"># Client의 물리 NIC 각각에서 캡처 (bond 아닌 슬레이브 직접)
tcpdump -i ens5f0np0 -nn \
  &#39;src host &lt;server_IP&gt; and tcp[tcpflags] &amp; (tcp-syn|tcp-ack) == (tcp-syn|tcp-ack)&#39; &amp;

tcpdump -i ens5f1np1 -nn \
  &#39;src host &lt;server_IP&gt; and tcp[tcpflags] &amp; (tcp-syn|tcp-ack) == (tcp-syn|tcp-ack)&#39; &amp;

# NIC 레벨에서도 안 보이면 → 스위치까지 못 간 것
# 특정 슬레이브에서만 보이면 → bond 수신 분산 문제</code></pre>
<h3 id="6-server-nic-카운터로-실제-tx-확인">6. Server NIC 카운터로 실제 TX 확인</h3>
<pre><code class="language-bash"># SYN-ACK가 실제로 wire에 나갔는지
watch -n 1 &#39;ethtool -S ens5f1np1 | grep -E &quot;tx_packets|tx_bytes&quot;&#39;

# 연결 실패 시점에 TX 카운터가 올라가는지 확인</code></pre>
<hr>
<h2 id="가장-가능성-높은-원인-두-가지">가장 가능성 높은 원인 두 가지</h2>
<p><strong>원인 A — L3 스위치의 per-packet ECMP</strong></p>
<p>스위치가 flow 단위가 아닌 packet 단위로 ECMP 분산을 하면, SYN-ACK가 Client와 연결되지 않은 다른 uplink로 나가 소실됩니다. 1%라는 확률은 특정 패킷 크기나 타이밍에서만 해싱 결과가 달라지는 경우와 일치합니다.</p>
<p><strong>원인 B — BGP route flap 또는 광고 경쟁</strong></p>
<p>ClusterMesh 환경에서 Client 노드 IP에 대한 BGP 경로가 순간적으로 변경되거나, 두 클러스터에서 동일 IP 대역을 광고 중이라면 SYN-ACK가 엉뚱한 노드로 전달될 수 있습니다.</p>
<pre><code class="language-bash"># BGP route 변동 확인
cilium bgp routes available ipv4 unicast 2&gt;&amp;1 | \
  grep &lt;client_노드_CIDR&gt; | sort | uniq -c
# 같은 CIDR에 next-hop이 2개 이상이면 경쟁 상태</code></pre>
<hr>
<p><code>ip route get &lt;client_IP&gt;</code> 결과와 <code>cilium bgp routes</code> 결과를 공유해 주시면 어느 쪽인지 바로 특정할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A21d]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A21d</link>
            <guid>https://velog.io/@youngkyoo_kim/26A21d</guid>
            <pubDate>Mon, 20 Apr 2026 22:09:47 GMT</pubDate>
            <description><![CDATA[<p>패킷이 외부 스위치에서 들어와 실제 애플리케이션(User Space)까지 도달하는 과정은 매우 복잡하지만, 플랫폼 엔지니어링 관점에서 중요한 <strong>&#39;데이터의 전이 과정&#39;</strong>을 중심으로 단계별로 정리해 드립니다.</p>
<p>특히 현재 겪고 계신 <code>native routing</code> 및 <code>Cilium eBPF</code> 환경에 맞춰 최적화된 흐름입니다.</p>
<hr>
<h2 id="1-하드웨어-및-드라이버-단계-nic--ram">1. 하드웨어 및 드라이버 단계 (NIC ~ RAM)</h2>
<p>패킷이 물리적인 전기 신호에서 메모리 상의 데이터로 변환되는 단계입니다.</p>
<ol>
<li><strong>L3 스위치 -&gt; NIC:</strong> 패킷이 물리 인터페이스에 도착합니다.</li>
<li><strong>RX Ring Buffer:</strong> NIC는 수신한 패킷을 호스트 메모리(RAM)의 예약된 공간인 <strong>RX Ring Buffer</strong>에 DMA(Direct Memory Access) 방식으로 기록합니다.</li>
<li><strong>Hard IRQ (인터럽트):</strong> NIC가 CPU에 &quot;데이터가 왔다&quot;고 인터럽트를 보냅니다. CPU는 수행 중인 작업을 멈추고 드라이버의 인터럽트 핸들러를 실행합니다.</li>
<li><strong>NAPI &amp; SoftIRQ:</strong> 커널은 인터럽트 폭주를 막기 위해 <strong>NAPI(New API)</strong> 모드로 전환합니다. 이때부터는 인터럽트 방식이 아닌 폴링(Polling) 방식으로 패킷을 가져오며, 이 작업은 커널 스레드인 <code>ksoftirqd</code>에 의해 <strong>SoftIRQ</strong> 레벨에서 처리됩니다.</li>
</ol>
<hr>
<h2 id="2-cilium--ebpf-단계-native-routing-gateway">2. Cilium &amp; eBPF 단계 (Native Routing Gateway)</h2>
<p>커널의 메인 네트워크 스택에 진입하기 전, Cilium이 주입한 eBPF 프로그램이 패킷을 먼저 가로챕니다.</p>
<ol>
<li><strong>XDP (옵션):</strong> 만약 Cilium이 XDP(eXpress Data Path) 모드로 동작 중이라면, NIC 드라이버 레벨에서 즉시 패킷을 처리(Drop, Pass, Redirect)합니다.</li>
<li><strong>TC (Traffic Control) Ingress:</strong> 일반적인 경우, 패킷은 <code>tc</code> 훅에 로드된 Cilium eBPF 프로그램에 의해 검사됩니다.<ul>
<li><strong>Identity 확인:</strong> 소스 IP를 기반으로 Cilium 보안 ID를 식별합니다.</li>
<li><strong>Policy Enforcement:</strong> 허용된 트래픽인지(Network Policy) 확인합니다.</li>
<li><strong>Load Balancing:</strong> 서비스 IP인 경우 실제 Pod IP로 목적지 주소를 변환(DNAT)합니다.</li>
</ul>
</li>
<li><strong>Native Routing:</strong> 터널링(VXLAN)이 아니므로 캡슐화 해제 과정 없이 바로 다음 커널 스택으로 패킷을 넘깁니다.</li>
</ol>
<hr>
<h2 id="3-커널-네트워크-레이어-l3-ip-stack">3. 커널 네트워크 레이어 (L3: IP Stack)</h2>
<p>여기서부터는 리눅스 커널의 표준 네트워킹 로직이 작동합니다.</p>
<ol>
<li><strong><code>ip_rcv()</code>:</strong> IP 헤더의 무결성을 검사합니다.</li>
<li><strong>Netfilter (PREROUTING):</strong> <code>iptables</code>나 <code>nftables</code>의 규칙이 적용됩니다. (Cilium은 이를 상당 부분 우회하지만 여전히 존재합니다.)</li>
<li><strong>Routing Decision (<code>fib_lookup</code>):</strong> 이 패킷이 로컬 프로세스용인지, 아니면 다른 곳으로 포워딩할 것인지 결정합니다.</li>
<li><strong><code>ip_local_deliver()</code>:</strong> 로컬 목적지 패킷임이 확인되면, 파편화된 패킷을 재조립하고 상위 계층(TCP/UDP)으로 전달합니다.</li>
</ol>
<hr>
<h2 id="4-tcp-트랜스포트-레이어-l4-tcp-stack">4. TCP 트랜스포트 레이어 (L4: TCP Stack)</h2>
<p>가장 복잡하고 논리적인 검증이 일어나는 단계입니다. (이전 질문의 드랍 지점)</p>
<ol>
<li><strong><code>tcp_v4_rcv()</code>:</strong> 패킷의 TCP 헤더를 읽고 해당 패킷을 처리할 <strong>소켓(Socket)</strong>을 찾습니다.</li>
<li><strong>TCP 상태 머신 확인:</strong><ul>
<li><strong>Handshake 단계:</strong> 패킷이 <code>SYN</code>에 대한 응답(<code>ACK</code>)인 경우, <strong><code>tcp_check_req()</code></strong>를 호출하여 <code>request_sock</code>(미니소켓) 상태를 검증합니다. (여기서 실패하면 드랍됩니다.)</li>
<li><strong>Established 단계:</strong> 이미 연결된 세션이라면 시퀀스 번호와 윈도우 크기가 합당한지 체크합니다.</li>
</ul>
</li>
<li><strong>TCP Receive Buffer:</strong> 검증이 끝난 패킷의 페이로드(데이터)를 해당 소켓의 <strong>수신 버퍼(Receive Queue)</strong>에 저장합니다.</li>
</ol>
<hr>
<h2 id="5-소켓-및-애플리케이션-단계-the-destination">5. 소켓 및 애플리케이션 단계 (The Destination)</h2>
<p>마지막으로 데이터가 커널 메모리에서 유저 메모리로 이동합니다.</p>
<ol>
<li><strong>Wake up Process:</strong> 수신 버퍼에 데이터가 들어오면, 해당 소켓을 기다리며 <code>sleep</code> 상태에 있던 애플리케이션 프로세스를 깨웁니다.</li>
<li><strong>System Call (<code>read</code> / <code>recv</code>):</strong> 애플리케이션(예: sshd, Go/Java App)이 시스템 콜을 호출합니다.</li>
<li><strong>Context Switch &amp; Data Copy:</strong> CPU 모드가 Kernel Mode에서 User Mode로 전환되며, 커널 메모리에 있던 데이터가 <strong>애플리케이션의 메모리 공간으로 복사(Copy)</strong>됩니다.</li>
<li><strong>Application Logic:</strong> 드디어 애플리케이션이 데이터를 읽어 비즈니스 로직을 수행합니다.</li>
</ol>
<hr>
<h3 id="정리-대규모-인프라-운영-시-핵심-포인트"><strong>정리: 대규모 인프라 운영 시 핵심 포인트</strong></h3>
<ul>
<li><strong>성능 병목:</strong> 보통 <strong>SoftIRQ</strong> 단계에서 CPU 0번 등에 부하가 몰려 패킷 드랍이 발생하기 쉽습니다. (RSS/RPS 튜닝 필요)</li>
<li><strong>현재 이슈 지점:</strong> <code>tcp_check_req</code> 단계는 <strong>커널 스택(4단계)</strong>에 해당하며, 이는 하드웨어나 Cilium의 결정이 아니라 커널이 가진 <strong>&#39;연결 상태 정보&#39;와 &#39;도착한 패킷&#39;이 일치하지 않을 때</strong> 발생하는 논리적 거부 단계입니다.</li>
</ul>
<p>이 과정 중 특히 어느 레이어의 메트릭이 가장 의심되시나요? 각 단계별로 더 깊게 파고들고 싶은 부분이 있다면 말씀해 주세요.</p>
<p>===</p>
<p>TCP 계층은 리눅스 커널에서 가장 똑똑하면서도 까칠한 영역입니다. 단순히 패킷을 넘기는 L3와 달리, <strong>&quot;이 패킷이 내가 기다리던 게 맞나?&quot;</strong>를 집요하게 따지기 때문이죠.</p>
<p>플랫폼 엔지니어로서 <code>pwru</code> 트레이스에서 보셨던 <code>tcp_check_req</code> 단계를 포함해, L4 스택 내부에서 벌어지는 일을 5단계로 더 자세히 풀어보겠습니다.</p>
<hr>
<h2 id="1-소켓-찾기-the-socket-lookup">1. 소켓 찾기 (The Socket Lookup)</h2>
<p>패킷이 L3(IP)를 통과해 <code>tcp_v4_rcv()</code> 함수에 진입하면 가장 먼저 하는 일은 <strong>&quot;이 패킷의 주인이 누구인가?&quot;</strong>를 찾는 것입니다.</p>
<ul>
<li><strong>4-Tuple 매칭:</strong> 패킷 헤더의 <code>Source IP/Port</code>, <code>Destination IP/Port</code> 정보를 추출합니다.</li>
<li><strong>__inet_lookup_skb():</strong> 커널은 해시 테이블에서 이 4-Tuple에 해당하는 소켓 구조체(<code>struct sock</code>)를 검색합니다.<ul>
<li>이미 연결된 소켓이 있다면 <strong>Established Hash</strong>에서 찾고,</li>
<li>새로운 연결 시도라면 <strong>Listening Hash</strong>에서 포트 번호를 기준으로 찾습니다.</li>
</ul>
</li>
<li><strong>결과:</strong> 소켓을 찾지 못하면 즉시 <code>RST</code>를 보내고 패킷을 버립니다.</li>
</ul>
<hr>
<h2 id="2-상태별-분기-the-dispatcher">2. 상태별 분기 (The Dispatcher)</h2>
<p>소켓을 찾았다면, 이제 소켓의 현재 상태(<code>sk_state</code>)에 따라 패킷 처리 경로가 완전히 달라집니다. <code>tcp_v4_do_rcv()</code>가 이 역할을 수행합니다.</p>
<ul>
<li><strong>ESTABLISHED 상태:</strong> 패킷이 이미 연결된 세션의 일부라면 <strong>Fast Path</strong>(빠른 처리 경로)로 보냅니다.</li>
<li><strong>LISTEN/SYN_SENT 상태:</strong> 핸드셰이크 과정 중이라면 <strong>Slow Path</strong>로 진입하여 복잡한 검증 로직을 태웁니다. <ul>
<li><strong>여기서 <code>tcp_check_req()</code>가 호출됩니다.</strong></li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-핸드셰이크-검증-the-gatekeeper-tcp_check_req">3. 핸드셰이크 검증 (The Gatekeeper: <code>tcp_check_req</code>)</h2>
<p>질문하셨던 드랍 지점이 바로 여기입니다. 서버가 <code>SYN</code>을 받고 <code>SYN-ACK</code>를 보낸 후, 클라이언트의 마지막 <code>ACK</code>를 기다리는 <strong>SYN_RECV</strong> 상태에서의 로직입니다.</p>
<ul>
<li><strong>Mini-Socket (request_sock):</strong> 커널은 메모리 절약을 위해 정식 소켓을 만들기 전, 아주 작은 &#39;임시 소켓&#39;인 <code>request_sock</code>을 만들어 둡니다.</li>
<li><strong>검증 항목:</strong><ol>
<li><strong>Sequence Number:</strong> 도착한 ACK 번호가 내가 보낸 SYN-ACK의 시퀀스 + 1인가?</li>
<li><strong>TCP Timestamps:</strong> (PAWS) 시간이 거꾸로 흐르지는 않았나?</li>
<li><strong>SYN Cookies:</strong> (부하 시) 쿠키 값이 일치하나?</li>
</ol>
</li>
<li><strong>통과 시:</strong> 임시 소켓을 정식 소켓(<code>struct tcp_sock</code>)으로 승격(Promote)시키고 <code>ESTABLISHED</code> 상태로 전이합니다. </li>
<li><strong>실패 시:</strong> 여기서 <code>pwru</code>에 찍힌 것처럼 <strong>드랍</strong> 처리됩니다.</li>
</ul>
<hr>
<h2 id="4-데이터-경로-fast-path-vs-slow-path">4. 데이터 경로 (Fast Path vs. Slow Path)</h2>
<p>연결이 수립된 후 데이터를 주고받을 때는 성능을 위해 두 가지 경로를 사용합니다.</p>
<ul>
<li><strong>Fast Path (Header Prediction):</strong> 패킷이 예상된 시퀀스 번호를 가졌고, 별다른 TCP 옵션이 없는 &#39;순수 데이터&#39;라면 헤더만 쓱 보고 바로 수신 큐로 넘깁니다. (성능 최적화)</li>
<li><strong>Slow Path:</strong> 패킷 순서가 뒤바뀌었거나(Out-of-order), SACK 옵션이 붙었거나, 윈도우 크기가 변하는 등 &#39;신경 쓸 게 많은&#39; 패킷은 이 경로에서 정밀하게 재조립됩니다.</li>
</ul>
<hr>
<h2 id="5-수신-큐와-애플리케이션-전달-the-delivery">5. 수신 큐와 애플리케이션 전달 (The Delivery)</h2>
<p>검증이 끝난 데이터 페이로드는 소켓의 <strong>수신 큐(Receive Queue)</strong>에 쌓입니다.</p>
<ul>
<li><strong>sk_buff 관리:</strong> 패킷 데이터가 담긴 <code>sk_buff</code> 구조체들이 리스트 형태로 큐에 매달립니다.</li>
<li><strong>Wake up:</strong> 큐에 데이터가 들어오면, 이 소켓을 물고 <code>epoll</code>이나 <code>read()</code> 시스템 콜로 대기 중이던 애플리케이션 프로세스를 깨웁니다.</li>
<li><strong>Copy to User:</strong> 애플리케이션이 깨어나면 커널 메모리에 있는 데이터를 유저 메모리 공간으로 복사해 갑니다.</li>
</ul>
<hr>
<h3 id="devops-관점에서의-요약"><strong>DevOps 관점에서의 요약</strong></h3>
<p>지금 겪고 계신 문제는 <strong>3단계(Gatekeeper)</strong>에서 발생하고 있습니다. L3 스위치만 있는 환경에서 이 단계의 드랍이 발생한다는 것은, 하드웨어 에러보다는 <strong>&quot;네트워크 지연으로 인해 서버가 클라이언트를 기다리다 지쳐(Minisock Timeout) 정보를 삭제했는데, 뒤늦게 클라이언트의 ACK가 도착한 경우&quot;</strong>가 가장 유력합니다.</p>
<p>RHEL 10에서도 이 핵심 로직은 동일하며, <code>nstat</code>으로 확인하신 <code>TcpExtTCPMinisocksDrop</code>이 이 3단계를 대변하는 지표라고 보시면 됩니다.</p>
<p>다음 단계로 넘어가기 위해 <code>nstat</code>의 구체적인 수치를 한 번 확인해 보시겠어요? 어떤 카운터가 가장 높은지에 따라 튜닝할 <code>sysctl</code> 변수가 결정됩니다.</p>
<p>===</p>
<p><code>nstat</code>은 리눅스 커널의 네트워크 통계 정보(<code>/proc/net/netstat</code> 및 <code>/proc/net/snmp</code>)를 실시간으로 읽어와서 보여주는 강력한 도구입니다. 대규모 인프라를 관리하신다면 <code>netstat -s</code>보다 가독성이 좋고 증분값(Delta) 확인이 쉬운 <code>nstat</code>을 주력으로 사용하시는 것이 좋습니다.</p>
<p>플랫폼 엔지니어링 실무에서 가장 많이 쓰이는 확인 방법들을 정리해 드립니다.</p>
<hr>
<h2 id="1-기본-사용법-및-핵심-옵션">1. 기본 사용법 및 핵심 옵션</h2>
<p><code>nstat</code>은 실행 시점 사이의 <strong>변화량(증분)</strong>을 보여주는 것이 기본 동작입니다.</p>
<ul>
<li><code>nstat -z</code>: 값이 0인 지표까지 모두 포함해서 출력 (전체 지표 확인 시 유용)</li>
<li><code>nstat -a</code>: 증분값이 아닌, 부팅 이후 누적된 <strong>전체 합계(Absolute)</strong> 출력</li>
<li><code>nstat -n</code>: 현재까지의 통계를 한 번 &#39;0&#39;으로 리셋 (다음 실행 때 깨끗한 증분 확인 가능)</li>
</ul>
<hr>
<h2 id="2-장애-진단-시-필수-명령어-필터링">2. 장애 진단 시 필수 명령어 (필터링)</h2>
<p>앞서 논의한 <code>tcp_check_req</code> 드롭이나 핸드셰이크 문제를 진단할 때 아래 명령어를 사용하세요.</p>
<h3 id="①-현재-발생-중인-드롭-지표-실시간-확인">① 현재 발생 중인 드롭 지표 실시간 확인</h3>
<p>명령어를 실행하고 1~2초 뒤에 다시 실행하면 그 사이의 변화량만 딱 찍힙니다.</p>
<pre><code class="language-bash"># -z를 붙여야 현재 0이라도 나중에 올라오는 지표를 미리 볼 수 있습니다.
nstat -z | grep -E &#39;TcpExtListenDrops|TcpExtSyncookiesFailed|TcpExtTCPMinisocksDrop&#39;</code></pre>
<h3 id="②-누적-카운트-확인-부팅-후-총합">② 누적 카운트 확인 (부팅 후 총합)</h3>
<p>장애가 이미 지나갔다면 누적값을 보고 시스템의 전반적인 건강 상태를 판단합니다.</p>
<pre><code class="language-bash">nstat -az | grep -E &#39;TcpExtListenDrops|TcpExtSyncookiesFailed|TcpExtTCPMinisocksDrop&#39;</code></pre>
<hr>
<h2 id="3-플랫폼-엔지니어를-위한-추가-유용-지표">3. 플랫폼 엔지니어를 위한 추가 유용 지표</h2>
<p>SSH 연결 지연이나 간헐적 끊김이 있을 때 함께 보면 좋은 지표들입니다.</p>
<table>
<thead>
<tr>
<th align="left">지표명</th>
<th align="left">의미</th>
<th align="left">해석 가이드</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>TcpExtTCPTimeouts</strong></td>
<td align="left">TCP 재전송 타임아웃 발생</td>
<td align="left">네트워크 경로상에 패킷 손실이 있음을 시사</td>
</tr>
<tr>
<td align="left"><strong>TcpExtTCPBacklogDrop</strong></td>
<td align="left">백로그 큐가 가득 차서 드롭</td>
<td align="left">커널이 패킷을 처리하는 속도보다 유입 속도가 빠름</td>
</tr>
<tr>
<td align="left"><strong>TcpExtSyncookiesSent</strong></td>
<td align="left">SYN Cookie 발동 횟수</td>
<td align="left">SYN Backlog가 꽉 찼음을 의미 (튜닝 신호)</td>
</tr>
<tr>
<td align="left"><strong>TcpExtTCPACKSkipped</strong></td>
<td align="left">ACK 처리를 건너뜀</td>
<td align="left">주로 윈도우/시퀀스 불일치 시 발생</td>
</tr>
</tbody></table>
<pre><code class="language-bash"># 위 지표들을 한꺼번에 모니터링하기
nstat -z | grep -E &#39;Timeout|BacklogDrop|SyncookiesSent|ACKSkipped&#39;</code></pre>
<hr>
<h2 id="4-실무-팁-1초마다-변화량-관찰하기">4. 실무 팁: 1초마다 변화량 관찰하기</h2>
<p>장애 상황을 재현하면서 실시간으로 수치가 올라가는 것을 보고 싶을 때 유용합니다. <code>watch</code> 명령어를 조합하세요.</p>
<pre><code class="language-bash"># 1초마다 nstat을 실행하고(증분 확인), 이전에 확인한 값은 리셋(-n)
watch -n 1 &quot;nstat -n | grep -E &#39;TcpExtListenDrops|TcpExtSyncookiesFailed|TcpExtTCPMinisocksDrop&#39;&quot;</code></pre>
<hr>
<h2 id="5-결과-해석-예시">5. 결과 해석 예시</h2>
<p>명령어를 실행했을 때 아래와 같이 나온다면 어떻게 판단해야 할까요?</p>
<pre><code class="language-text">TcpExtListenDrops              15                 0.0
TcpExtTCPMinisocksDrop         120                0.0</code></pre>
<ul>
<li><strong>해석:</strong> 마지막으로 <code>nstat</code>을 실행한 이후, <strong>ListenDrops</strong>는 15회, <strong>TCPMinisocksDrop</strong>은 120회 발생했다는 뜻입니다.</li>
<li><strong>판단:</strong> <code>MinisocksDrop</code>이 압도적으로 많다면, 클라이언트의 ACK가 늦게 와서 커널이 기다리다 지쳐 버린 경우가 많으므로 <strong>네트워크 지연(Latency)</strong>이나 <strong>RTT</strong>를 먼저 의심해야 합니다.</li>
</ul>
<p>RHEL 10에서도 이 명령어 체계와 결과 형식은 동일하게 유지됩니다. 지금 시스템에서 <code>nstat -az</code>를 실행했을 때 <strong><code>TcpExtTCPMinisocksDrop</code></strong> 수치가 다른 드롭 지표들에 비해 유독 높게 나오나요?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A21c]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A21c</link>
            <guid>https://velog.io/@youngkyoo_kim/26A21c</guid>
            <pubDate>Mon, 20 Apr 2026 21:55:41 GMT</pubDate>
            <description><![CDATA[<p>네, <strong>개선 가능합니다.</strong> 튜닝의 방향은 크게 두 가지입니다. 하나는 <strong>SYN Cookie 모드로 진입 자체를 하지 않게 큐(Queue)를 늘리는 것</strong>이고, 다른 하나는 <strong>SYN Cookie가 동작하더라도 검증 실패가 나지 않도록 신뢰성을 높이는 것</strong>입니다.</p>
<p>DevOps 관점에서 대규모 트래픽이나 간헐적인 부하를 견딜 수 있도록 하는 핵심 튜닝 포인트를 정리해 드립니다.</p>
<hr>
<h2 id="1-syn-cookie-진입-방지-queue-확장">1. SYN Cookie 진입 방지 (Queue 확장)</h2>
<p>SYN Cookie는 <code>SYN Queue</code>(반개방 소켓 큐)가 가득 찼을 때만 발동합니다. 큐 크기를 충분히 키워두면 시스템이 &#39;비상 모드&#39;인 SYN Cookie를 사용하지 않고도 요청을 처리할 수 있습니다.</p>
<ul>
<li><strong><code>net.ipv4.tcp_max_syn_backlog</code></strong>: <ul>
<li>아직 핸드셰이크가 완료되지 않은 요청(SYN_RECV 상태)을 얼마나 저장할지 결정합니다.</li>
<li><strong>권장:</strong> 1000개 노드 규모라면 <code>4096</code> 이상, 아주 많게는 <code>16384</code>까지 설정합니다.</li>
</ul>
</li>
<li><strong><code>net.core.somaxconn</code></strong>:<ul>
<li>애플리케이션(여기서는 SSHD)이 <code>accept()</code>를 기다리는 완전 연결 큐의 크기입니다. 이 값이 작으면 핸드셰이크가 끝나도 드랍될 수 있습니다.</li>
<li><strong>권장:</strong> 최소 <code>4096</code> 이상.</li>
</ul>
</li>
</ul>
<pre><code class="language-bash"># 실시간 적용
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192
sudo sysctl -w net.core.somaxconn=8192</code></pre>
<hr>
<h2 id="2-syn-cookie-검증-실패-방지-tcp-timestamp">2. SYN Cookie 검증 실패 방지 (TCP Timestamp)</h2>
<p>SYN Cookie가 활성화되었을 때 검증이 실패하는 가장 흔한 이유는 <strong>TCP Timestamps</strong>가 어긋나거나 비활성화되어 있기 때문입니다. SYN Cookie는 TCP 헤더의 특정 필드를 빌려 정보를 저장하는데, 이때 Timestamp 필드가 중요하게 사용됩니다.</p>
<ul>
<li><strong><code>net.ipv4.tcp_timestamps</code></strong>:<ul>
<li>이 값이 <code>0</code>이면 SYN Cookie 내에 MSS(Maximum Segment Size) 정보를 제대로 담지 못해 검증 실패율이 올라갑니다.</li>
<li><strong>확인:</strong> <code>sysctl net.ipv4.tcp_timestamps</code>가 <strong>1</strong>인지 확인하십시오. (상대방 클라이언트도 1이어야 함)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-타임아웃-및-재전송-관련-튜닝">3. 타임아웃 및 재전송 관련 튜닝</h2>
<p>L3 스위치만 있는 환경에서 간헐적 지연이 발생한다면, 커널이 &#39;이 연결은 죽었다&#39;고 판단하는 시간을 아주 약간 넉넉하게 줄 수 있습니다.</p>
<ul>
<li><strong><code>net.ipv4.tcp_synack_retries</code></strong>:<ul>
<li>서버가 SYN-ACK를 보내고 클라이언트의 ACK를 기다리는 재전송 횟수입니다. 기본값은 보통 5인데, 네트워크가 불안정하다면 이 횟수가 소진되기 전에 큐에서 삭제될 수 있습니다. (다만 이 값을 너무 키우면 리소스 낭비가 생길 수 있으니 기본값 유지를 권장하되, 환경에 따라 2~3으로 조정하여 빠르게 소켓을 정리하기도 합니다.)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-진단-및-모니터링-실행-결과-확인">4. 진단 및 모니터링 (실행 결과 확인)</h2>
<p>튜닝 적용 후 실제로 효과가 있는지 보려면 다음 카운터를 계속 주시해야 합니다.</p>
<pre><code class="language-bash"># SYN Cookie 관련 실패 카운터 확인
nstat -az | grep -i Syncookies</code></pre>
<ul>
<li><code>TcpExtSyncookiesSent</code>: 서버가 쿠키를 보낸 횟수 (이게 올라가면 큐가 가득 찼다는 뜻)</li>
<li><code>TcpExtSyncookiesFailed</code>: <strong>이게 문제의 지표입니다.</strong> 쿠키 검증에 실패한 횟수입니다.</li>
</ul>
<hr>
<h2 id="종합-제안-결론">종합 제안 (결론)</h2>
<p>현재 SSH 연결 시 간헐적 문제가 발생한다면, 아래 설정을 <code>sysctl.conf</code>에 반영하여 큐 임계치를 대폭 높여보시는 것을 추천합니다.</p>
<ol>
<li><strong>우선 순위 1:</strong> <code>tcp_max_syn_backlog</code>와 <code>somaxconn</code>을 현재 값보다 <strong>4배 이상</strong> 늘립니다.</li>
<li><strong>우선 순위 2:</strong> <code>tcp_timestamps</code>가 <code>1</code>인지 확인합니다. 만약 클라이언트(VDI 등) 쪽에서 타임스탬프를 제거하고 있다면 쿠키 검증 실패는 튜닝만으로 해결이 어려울 수 있습니다.</li>
<li><strong>Cilium 특이사항:</strong> Native Routing 사용 시 노드 간의 MTU가 일치하지 않으면 <code>tcp_check_req</code> 이후의 실제 데이터 패킷에서 이슈가 생길 수 있으므로, <code>ip link</code>로 각 노드 인터페이스의 MTU가 1500(혹은 하단 스위치 설정값)으로 동일한지 꼭 체크해 보세요.</li>
</ol>
<p>이 튜닝 이후에도 <code>SyncookiesFailed</code> 카운트가 계속 올라간다면, 클라이언트 측에서 보내는 ACK 패킷 자체가 중간 네트워크 장비(L3 스위치 등)에 의해 변조되거나 지연되고 있을 가능성이 매우 높습니다.</p>
<p>혹시 현재 해당 서버들의 <code>max_syn_backlog</code> 설정값은 얼마로 되어 있나요?</p>
<p>===</p>
<p>네, 맞습니다. <code>pwru</code> 트레이스에서 드롭 지점이 <strong><code>tcp_check_req</code></strong>로 찍혔다면, 범인은 이미 포위망에 들어온 것과 다름없습니다. 일반적인 리눅스 커널 네트워킹 스택에서 이 단계는 <strong>&quot;3-way handshake의 마지막 퍼즐(ACK)을 맞추는 과정&quot;</strong>이기 때문입니다.</p>
<p>결론부터 말씀드리면, <strong>말씀하신 2~3가지 원인 외에 다른 가능성은 95% 이상 배제</strong>하셔도 무방합니다. 이 지점에서 패킷이 버려지는 시나리오는 매우 구체적입니다.</p>
<hr>
<h3 id="1-포위망에-들어온-3대-주범">1. 포위망에 들어온 3대 주범</h3>
<p>이전 모델이 제시한 원인들을 실무적인 관점에서 가장 확률 높은 순으로 다시 정리해 드립니다.</p>
<ol>
<li><p><strong>타이밍 이슈 (Request Sock Timeout):</strong></p>
<ul>
<li>서버가 <code>SYN-ACK</code>를 보냈는데, 클라이언트의 <code>ACK</code>가 너무 늦게 도착한 경우입니다. 서버는 이미 &quot;얘는 안 오나 보다&quot; 하고 관리 대장(<code>request_sock</code>)에서 해당 연결 정보를 지워버린 상태입니다.</li>
<li><strong>증거:</strong> <code>nstat -az | grep TcpExtListenDrops</code> 카운트 상승.</li>
</ul>
</li>
<li><p><strong>검증 로직 실패 (SYN Cookies &amp; Timestamps):</strong></p>
<ul>
<li>시스템 부하로 SYN Cookie가 발행되었는데, 도착한 <code>ACK</code>의 쿠키 값이 계산 결과와 다르거나, TCP Timestamps가 일치하지 않아 커널이 &quot;가짜 ACK&quot;라고 판단한 경우입니다.</li>
<li><strong>증거:</strong> <code>nstat -az | grep SyncookiesFailed</code>.</li>
</ul>
</li>
<li><p><strong>시퀀스 번호/윈도우 불일치 (Invalid ACK):</strong></p>
<ul>
<li>이미 연결이 종료되었거나 중복된 <code>SYN</code>이 오가는 과정에서, 클라이언트가 보낸 <code>ACK</code>의 Sequence 번호가 서버가 기대하는 범위(Window) 밖에 있는 경우입니다.</li>
<li>L3 스위치만 있는 환경이라도, 중간 장비에서 패킷 순서가 심하게 뒤바뀌면 발생할 수 있습니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="2-거의-없지만-존재는-하는-01의-가능성">2. &quot;거의 없지만&quot; 존재는 하는 0.1%의 가능성</h3>
<p>만약 위 3가지가 모두 아니라면, 정말 드문 확률로 다음 상황일 수 있습니다.</p>
<ul>
<li><strong>TCP MD5 Signature 미스매치:</strong> 만약 BGP 세션 등에서 TCP MD5 인증을 사용 중이라면(일반 SSH에서는 거의 안 씁니다), 서명이 틀렸을 때 여기서 드랍됩니다.</li>
<li><strong>커널 버그:</strong> 특정 커널 버전에서 <code>tcp_check_req</code> 로직의 레이스 컨디션(Race Condition)으로 인해 드랍되는 경우가 아주 드물게 보고된 적이 있습니다. (하지만 운영 환경에서는 거의 고려하지 않습니다.)</li>
<li><strong>Cilium eBPF의 커널 스택 간섭:</strong> <code>native routing</code>이라도 Cilium이 커널 스택으로 패킷을 넘기기 전에 <code>skb</code> 마킹을 이상하게 해서, 커널이 이를 &quot;오염된 패킷&quot;으로 간주하고 드랍할 가능성은 0에 가깝지만 존재합니다.</li>
</ul>
<hr>
<h3 id="3-제-판단-이것만-확인하면-끝납니다">3. 제 판단: &quot;이것만 확인하면 끝납니다&quot;</h3>
<p>현재 L3 스위치만 있는 클린한 환경이므로, <strong>&quot;네트워크 지연으로 인한 타임아웃&quot;</strong> 혹은 <strong>&quot;SYN Cookie 설정 불일치&quot;</strong> 둘 중 하나일 확률이 압도적입니다. </p>
<p><strong>확정 진단을 위한 마지막 한 걸음:</strong>
터미널에서 아래 한 줄만 실행해 보시고 결과를 알려주세요.</p>
<pre><code class="language-bash">nstat -az | grep -E &#39;TcpExtListenDrops|TcpExtSyncookiesFailed|TcpExtTCPMinisocksDrop&#39;</code></pre>
<ul>
<li><code>ListenDrops</code>가 올라가면 -&gt; <strong>백로그 큐(Backlog Queue) 확장 및 타임아웃 튜닝</strong>이 정답입니다.</li>
<li><code>SyncookiesFailed</code>가 올라가면 -&gt; <strong>TCP Timestamp 설정 확인 및 Syncookies 튜닝</strong>이 정답입니다.</li>
</ul>
<p>이 지점에서 드롭이 발생했다는 사실 자체가 이미 전체 디버깅 과정의 90%를 끝내신 겁니다. 아주 날카롭게 짚어내셨네요! 이외에 다른 카운터가 유독 높게 나오는 게 있나요?</p>
<p>===</p>
<p>네, RHEL 10(커널 6.x 대역 기반)에서도 이 메트릭들은 <strong>여전히 핵심적인 네트워킹 진단 지표</strong>로 존재합니다. 리눅스 커널의 TCP 스택이 고도화되면서 내부 로직의 성능은 개선되었지만, 운영 체제가 네트워크 상태를 보고하는 표준 방식(SNMP 카운터)은 하위 호환성과 관찰 가능성을 위해 유지되고 있기 때문입니다.</p>
<p>요청하신 세 가지 지표는 모두 <strong>&quot;TCP 핸드셰이크 과정 중 어디서 패킷이 버려졌는가&quot;</strong>를 정확히 짚어주는 이정표입니다.</p>
<hr>
<h2 id="1-주요-커널-메트릭-상세-설명">1. 주요 커널 메트릭 상세 설명</h2>
<h3 id="①-tcpextlistendrops-리스너-드롭">① <code>TcpExtListenDrops</code> (리스너 드롭)</h3>
<p>이 카운터는 <strong>&quot;완전 연결 큐(Accept Queue)가 꽉 찼을 때&quot;</strong> 올라갑니다.</p>
<ul>
<li><strong>상황:</strong> 클라이언트와 3-way handshake를 다 끝냈는데, 애플리케이션(예: sshd)이 너무 바빠서 <code>accept()</code> 시스템 콜로 연결을 가져가지 못할 때 발생합니다.</li>
<li><strong>의미:</strong> &quot;연결은 맺어졌지만, 서버 프로그램이 수용 가능한 한계를 넘었다&quot;는 뜻입니다.</li>
<li><strong>조치:</strong> <code>net.core.somaxconn</code> 값을 키우거나, 애플리케이션의 워커 스레드 수를 늘려야 합니다.</li>
</ul>
<h3 id="②-tcpextsyncookiesfailed-쿠키-검증-실패">② <code>TcpExtSyncookiesFailed</code> (쿠키 검증 실패)</h3>
<p>이 카운터는 <strong>&quot;도착한 ACK 패킷이 유효한 SYN Cookie가 아닐 때&quot;</strong> 올라갑니다.</p>
<ul>
<li><strong>상황:</strong> 서버가 SYN Flooding 등을 방어하기 위해 &#39;비상 모드(SYN Cookie)&#39;를 켰을 때, 클라이언트가 보낸 ACK에 담긴 &#39;쿠키 값&#39;이 서버가 계산한 값과 다를 경우입니다.</li>
<li><strong>의미:</strong> 중간에 방화벽이나 L3 스위치가 패킷을 변조했거나, TCP Timestamp가 일치하지 않아 서버가 &quot;이 패킷은 내가 보낸 SYN-ACK에 대한 정상적인 응답이 아니다&quot;라고 판단한 것입니다.</li>
<li><strong>조치:</strong> <code>net.ipv4.tcp_timestamps=1</code> 설정이 서버/클라이언트 양쪽 다 되어 있는지 확인하십시오.</li>
</ul>
<h3 id="③-tcpexttcpminisocksdrop-미니소켓-드롭">③ <code>TcpExtTCPMinisocksDrop</code> (미니소켓 드롭)</h3>
<p>이것은 현재 겪고 계신 <strong><code>tcp_check_req</code> 단계의 드롭과 가장 밀접한 관련</strong>이 있습니다.</p>
<ul>
<li><strong>상황:</strong> 리눅스 커널은 연결이 완전히 수립되기 전 상태(SYN_RECV)를 아주 작은 메모리 구조체인 <code>request_sock</code>(Minisock)으로 관리합니다. 이 소켓이 모종의 이유로 드랍될 때 올라갑니다.</li>
<li><strong>의미:</strong> 주로 <strong>타임아웃</strong> 때문입니다. 서버가 SYN-ACK를 보낸 후 클라이언트의 ACK를 기다리다가, 기다림이 너무 길어져서(또는 메모리가 부족해서) 이 미니소켓을 메모리에서 해제해 버린 것입니다. 그 직후에 클라이언트의 ACK가 도착하면 서버는 &quot;누구세요?&quot;라며 패킷을 버리게 됩니다.</li>
<li><strong>조치:</strong> 네트워크 지연(RTT)을 체크하거나, 서버의 <code>request_sock</code> 유지 시간을 점검해야 합니다.</li>
</ul>
<hr>
<h2 id="2-rhel-10에서의-상태">2. RHEL 10에서의 상태</h2>
<p><strong>RHEL 10에서도 이 값들은 동일하게 <code>/proc/net/netstat</code>에서 확인할 수 있습니다.</strong></p>
<p>리눅스 커널 개발팀은 성능을 위해 <code>lockless listener</code> 등 새로운 메커니즘을 도입해 왔지만, 운영자가 문제를 진단할 때 사용하는 <strong>SNMP(Simple Network Management Protocol) 통계 이름</strong>은 바꾸지 않았습니다. 덕분에 RHEL 7, 8, 9에서 사용하시던 진단 스크립트나 모니터링 에이전트(Prometheus Node Exporter 등)를 RHEL 10에서도 그대로 사용하실 수 있습니다.</p>
<hr>
<h2 id="3-요약-및-진단-가이드">3. 요약 및 진단 가이드</h2>
<p>플랫폼 엔지니어로서 현상을 분석하실 때, 아래 표를 기준으로 드롭의 성격을 규정하시면 됩니다.</p>
<table>
<thead>
<tr>
<th align="left">메트릭 명칭</th>
<th align="left">드롭 시점</th>
<th align="left">주요 원인</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>ListenDrops</strong></td>
<td align="left">핸드셰이크 <strong>완료 후</strong></td>
<td align="left">애플리케이션 처리 지연 (<code>somaxconn</code> 부족)</td>
</tr>
<tr>
<td align="left"><strong>SyncookiesFailed</strong></td>
<td align="left">핸드셰이크 <strong>진행 중</strong></td>
<td align="left">패킷 변조, Timestamp 불일치, 보안 장비 간섭</td>
</tr>
<tr>
<td align="left"><strong>TCPMinisocksDrop</strong></td>
<td align="left">핸드셰이크 <strong>진행 중</strong></td>
<td align="left"><strong>네트워크 지연으로 인한 타임아웃</strong>, 리소스 부족</td>
</tr>
</tbody></table>
<p><strong>결론:</strong>
만약 <code>pwru</code>에서 <code>tcp_check_req</code> 드롭이 보인다면, RHEL 10에서도 <code>TCPMinisocksDrop</code>이나 <code>SyncookiesFailed</code> 중 하나가 함께 올라가고 있을 확률이 매우 높습니다. </p>
<p>지금 노드에서 <code>nstat -az | grep -i TcpExt</code> 결과가 어떻게 나오나요? 특정 수치가 유독 높다면 그게 바로 범인입니다.</p>
<p>===</p>
<p>네, SYN cookie 검증 실패는 근본 원인에 따라 접근이 달라집니다. 튜닝 방향을 두 가지로 나눌 수 있어요.</p>
<hr>
<h2 id="핵심-전제-syn-cookie가-왜-발행되는가">핵심 전제: SYN cookie가 왜 발행되는가</h2>
<p>SYN cookie는 <strong>SYN 큐(backlog)가 가득 찼을 때</strong> 커널이 어쩔 수 없이 꺼내는 fallback입니다. 따라서 튜닝 방향은 두 가지입니다.</p>
<ol>
<li>SYN 큐가 안 차게 → cookie 자체를 안 쓰게</li>
<li>어쩔 수 없이 cookie를 쓸 때 → 검증 실패를 줄이게</li>
</ol>
<hr>
<h2 id="방향-1--syn-큐가-차지-않도록-근본-해결">방향 1 — SYN 큐가 차지 않도록 (근본 해결)</h2>
<pre><code class="language-bash"># SYN 큐 크기 확장 (기본 128~256, 대규모 클러스터는 4096 이상 권장)
sysctl -w net.ipv4.tcp_max_syn_backlog=4096

# 애플리케이션 listen() backlog 상한 (sshd는 보통 128 고정)
sysctl -w net.core.somaxconn=4096

# SYN-ACK 재전송 횟수 줄이기 (대기 시간 단축 → 큐 빨리 비움)
# 기본 5 (약 180초 대기), 2로 줄이면 약 45초
sysctl -w net.ipv4.tcp_synack_retries=2</code></pre>
<p>sshd 자체도 <code>MaxStartups</code> 값을 확인해야 합니다.</p>
<pre><code># /etc/ssh/sshd_config
MaxStartups 100:30:200   # 동시 미완성 핸드셰이크 허용 수</code></pre><hr>
<h2 id="방향-2--syn-cookie-사용-시-검증-실패-줄이기">방향 2 — SYN cookie 사용 시 검증 실패 줄이기</h2>
<p>SYN cookie 검증 실패의 주요 원인은 <strong>MSS 옵션 정보 손실</strong>입니다. Cookie에는 MSS가 3비트(8가지 값)밖에 인코딩되지 않아, 경로 MTU가 불일치하거나 패킷이 변조되면 검증이 깨집니다.</p>
<pre><code class="language-bash"># syncookies 활성화 확인 (2 = 항상 사용, 1 = 큐 찼을 때만 → 1 권장)
sysctl -w net.ipv4.tcp_syncookies=1</code></pre>
<p>값을 <code>1</code>로 유지하면 큐가 차지 않는 한 cookie를 쓰지 않아 검증 실패 자체가 줄어듭니다. <code>2</code>로 설정하면 항상 cookie를 써서 오히려 실패 빈도가 높아집니다.</p>
<hr>
<h2 id="ecmp--cilium-환경-특화-튜닝">ECMP + Cilium 환경 특화 튜닝</h2>
<p>이 환경에서는 추가로 고려할 게 있습니다.</p>
<p><strong>Cilium의 <code>tcp_max_syn_backlog</code> 반영 확인</strong>
Cilium이 eBPF로 LB를 할 때 자체 session affinity 없이 ECMP를 쓰면, SYN은 노드 A로, ACK는 노드 B로 가는 비대칭이 발생합니다. 이 경우 cookie 튜닝만으로는 한계가 있고, 아래가 더 효과적입니다.</p>
<pre><code class="language-bash"># Cilium session affinity 활성화 (helm values)
# loadBalancer.sessionAffinity: true
# 또는 BGP에서 ECMP 해싱을 5-tuple 기반으로 고정

# 스위치에서 consistent hashing 확인 필요
# src-ip, dst-ip, src-port, dst-port, protocol 모두 포함되어야 함</code></pre>
<hr>
<h2 id="현재-상태-확인-→-튜닝-전후-비교">현재 상태 확인 → 튜닝 전후 비교</h2>
<pre><code class="language-bash"># 튜닝 전 baseline 수집
nstat -az | grep -E &#39;SyncookiesSent|SyncookiesFailed|SyncookiesRecv|ListenDrops|ListenOverflows&#39;

# 설정 적용 후 변화 모니터링 (5초 간격)
watch -n 5 &quot;nstat -az | grep -E &#39;Syncookies|ListenDrop&#39;&quot;</code></pre>
<p><code>SyncookiesSent</code>가 지속 증가하면 큐 포화가 원인이므로 방향 1, <code>SyncookiesFailed / SyncookiesRecv</code> 비율이 높으면 패킷 변조나 비대칭 경로 문제입니다.</p>
<hr>
<h2 id="권장-적용-순서">권장 적용 순서</h2>
<p><code>SyncookiesSent</code>가 높다면 <code>tcp_max_syn_backlog</code> + <code>somaxconn</code> 확장이 즉효입니다. 그래도 <code>SyncookiesFailed</code>가 계속 오른다면 ECMP 5-tuple 해싱 일관성 확인이 다음 단계입니다. 현재 어떤 카운터가 올라가고 있는지 공유해 주시면 더 구체적으로 좁혀드릴 수 있어요.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A21b]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A21b</link>
            <guid>https://velog.io/@youngkyoo_kim/26A21b</guid>
            <pubDate>Mon, 20 Apr 2026 20:45:51 GMT</pubDate>
            <description><![CDATA[<p>1,000노드 규모의 대규모 인프라에서 Cilium Native Mode와 BGP, ECMP를 조합해 사용하신다면, 네트워크 스택의 복잡도가 상당히 높을 것으로 예상됩니다. 특히 <strong>Asymmetric Routing(비대칭 라우팅)</strong>이나 <strong>ECMP Hashing 불일치</strong>가 간헐적 장애의 주범일 가능성이 큽니다.</p>
<p>요청하신 대로 수신 측(Receiver) 관점에서 패킷이 NIC을 거쳐 커널의 <code>tcp_check_req</code>에서 드랍되기까지의 구조와 주요 지점을 짚어드리겠습니다.</p>
<hr>
<h2 id="1-수신측-패킷-처리-구조-nic-to-kernel-tcp">1. 수신측 패킷 처리 구조 (NIC to Kernel TCP)</h2>
<p>Native Routing 환경에서는 캡슐화(VXLAN) 과정이 없기 때문에 패킷이 표준 리눅스 네트워킹 스택을 더 깊게 활용합니다.</p>
<h3 id="①-하드웨어-레벨-nic--driver">① 하드웨어 레벨: NIC &amp; Driver</h3>
<ul>
<li><strong>RSS (Receive Side Scaling):</strong> 패킷이 들어오면 NIC 하드웨어가 5-tuple 해싱을 통해 특정 RX 큐에 패킷을 할당합니다. ECMP 환경에서는 스위치단에서 이미 해싱되어 들어오지만, 본딩(Bonding)된 NIC 내부에서도 어떤 슬레이브로 들어오느냐가 결정됩니다.</li>
<li><strong>SoftIRQ (NAPI):</strong> 커널이 CPU를 할당하여 RX 큐에서 패킷을 꺼내 <code>sk_buff</code>(skb) 구조체로 만듭니다.</li>
</ul>
<h3 id="②-cilium--ebpf-계층-host-routing">② Cilium &amp; eBPF 계층 (Host Routing)</h3>
<ul>
<li><strong>XDP/tc BPF:</strong> Cilium은 <code>tc</code>(traffic control) 훅에 eBPF 프로그램을 붙여 패킷을 처리합니다. Native 모드이므로 BGP로 전파된 라우팅 테이블을 보고 패킷이 로컬 포드용인지, 다른 노드로 포워딩할지 결정합니다.</li>
<li><strong>Connection Tracking (CT):</strong> Cilium은 자체 BPF CT 테이블을 관리합니다. 여기서 상태가 어긋나면 커널 스택으로 넘기기 전에 드랍되는데, <strong>사용자님의 경우 Cilium Monitor에 안 찍히므로 이 단계는 통과한 것입니다.</strong></li>
</ul>
<h3 id="③-ip-stack--netfilter">③ IP Stack &amp; Netfilter</h3>
<ul>
<li><strong><code>ip_rcv</code> &amp; <code>nf_hook</code>:</strong> 패킷이 커널 네트워킹 스택으로 진입합니다. <code>iptables</code>의 PREROUTING 훅이 여기서 실행됩니다.</li>
<li><strong><code>fib_validate_source</code> (중요 - RP Filter):</strong> BGP+ECMP 환경에서 가장 위험한 구간입니다. 패킷이 들어온 인터페이스가 커널 라우팅 테이블상 &quot;해당 소스 IP로 나갈 때 사용하는 인터페이스&quot;와 다르면 <strong>비대칭 라우팅</strong>으로 간주하고 드랍합니다.</li>
</ul>
<h3 id="④-tcp-stack-tcp_v4_rcv---tcp_check_req">④ TCP Stack: <code>tcp_v4_rcv</code> -&gt; <code>tcp_check_req</code></h3>
<ul>
<li><strong><code>tcp_v4_rcv</code>:</strong> 패킷의 목적지가 로컬 소켓임을 확인하고 TCP 프로토콜 프로세서로 넘깁니다.</li>
<li><strong><code>tcp_check_req</code> (드랍 지점):</strong> TCP 3-way handshake의 마지막 <code>ACK</code>를 검증하는 핵심 함수입니다. <code>SYN</code>을 받았을 때 생성된 <strong><code>request_sock</code> (Mini-socket)</strong>과 들어온 <code>ACK</code>를 대조합니다.</li>
</ul>
<hr>
<h2 id="2-왜-tcp_check_req에서-드랍될까-전문가적-진단">2. 왜 <code>tcp_check_req</code>에서 드랍될까? (전문가적 진단)</h2>
<p>Native Mode, BGP, ECMP 환경을 고려할 때 가장 유력한 시나리오는 다음과 같습니다.</p>
<h3 id="시나리오-a-ecmp에-의한-비대칭-라우팅-asymmetric-routing">시나리오 A: ECMP에 의한 비대칭 라우팅 (Asymmetric Routing)</h3>
<ul>
<li><strong>현상:</strong> 클라이언트가 보낸 <code>SYN</code>은 노드 A로 들어왔는데, 마지막 <code>ACK</code>는 ECMP 경로 최적화나 스위치의 해싱 변경으로 인해 노드 B로 들어오는 경우입니다.</li>
<li><strong>결과:</strong> 노드 B는 해당 세션에 대한 <code>request_sock</code> 정보가 없으므로 &quot;듣도 보도 못한 ACK&quot;라 판단하고 <code>tcp_check_req</code>에서 드랍합니다. Clustermesh를 사용 중이라면 노드 간 라우팅 경로가 꼬일 확률이 더 높아집니다.</li>
</ul>
<h3 id="시나리오-b-request-sock-타임아웃">시나리오 B: Request Sock 타임아웃</h3>
<ul>
<li><strong>현상:</strong> 서버가 <code>SYN-ACK</code>를 보냈으나, 네트워크 지연으로 클라이언트의 <code>ACK</code>가 서버의 <code>TCP_TIMEOUT_INIT</code> 내에 도착하지 못한 경우입니다.</li>
<li><strong>결과:</strong> 서버 커널은 이미 <code>request_sock</code>을 해제(GC)했고, 뒤늦게 온 <code>ACK</code>는 매칭되는 정보가 없어 드랍됩니다.</li>
</ul>
<h3 id="시나리오-c-syn-cookie-오작동">시나리오 C: SYN Cookie 오작동</h3>
<ul>
<li><strong>현상:</strong> BGP를 통한 대규모 트래픽 유입으로 노드의 <code>Accept Queue</code>가 순간적으로 차오르면 <code>SYN Cookie</code>가 발행됩니다.</li>
<li><strong>결과:</strong> 클라이언트가 보낸 <code>ACK</code>에 포함된 쿠키 값이 커널의 현재 계산값과 미세하게 어긋나면 검증에 실패합니다.</li>
</ul>
<hr>
<h2 id="3-플랫폼-엔지니어를-위한-심화-디버깅-가이드">3. 플랫폼 엔지니어를 위한 심화 디버깅 가이드</h2>
<p>현재 상황에서 &quot;범인&quot;을 검거하기 위해 다음 순서로 확인을 권장합니다.</p>
<ol>
<li><p><strong>비대칭 라우팅 확인 (가장 의심됨):</strong></p>
<ul>
<li>드랍이 발생하는 노드에서 <code>sysctl net.ipv4.conf.all.rp_filter</code> 값을 확인하십시오.</li>
<li>BGP 환경이라면 <code>2 (Loose)</code> 모드로 설정되어 있어야 비대칭 경로를 허용합니다. 만약 <code>1 (Strict)</code>라면 즉시 드랍 사유가 됩니다.</li>
</ul>
</li>
<li><p><strong><code>nstat</code>를 통한 커널 통계 분석:</strong></p>
<pre><code class="language-bash"># 드랍 발생 시점 전후로 카운트 변화 관찰
nstat -az | grep -E &#39;TcpExtListenDrops|TcpExtTCPMinisocksDrop|TcpExtTCPACKSkipped&#39;</code></pre>
<ul>
<li><code>TcpExtListenDrops</code>가 올라간다면 큐 부족 문제입니다.</li>
<li><code>TcpExtTCPMinisocksDrop</code>이 올라간다면 <code>request_sock</code> 단계에서의 상태 불일치(비대칭 라우팅 등)입니다.</li>
</ul>
</li>
<li><p><strong>Cilium BGP 경로 안정성:</strong></p>
<ul>
<li>BGP 피어링이 간헐적으로 떨리거나(Flapping), ECMP 경로가 재계산되는지 스위치 로그와 함께 확인이 필요합니다.</li>
</ul>
</li>
</ol>
<p><strong>결론적으로 제 제안은 이렇습니다.</strong>
<code>pwru</code>가 드랍을 잡았다는 것은 패킷이 물리적으로는 도착했다는 증거입니다. 하지만 <code>tcpdump</code>가 못 잡는 이유는 보통 <strong><code>rp_filter</code>에 의한 초기 드랍</strong>이거나 <strong>본딩 인터페이스의 캡처 지점 문제</strong>입니다. 우선 노드의 <code>rp_filter</code> 설정을 <code>2</code>로 바꾸어 보시고, 현상이 해결되는지 확인하는 것이 가장 빠른 길입니다.</p>
<p>추가로 <code>nstat</code> 결과에서 특정 카운터가 유독 높게 나온다면 공유해 주세요. 바로 다음 단계를 짚어드리겠습니다.</p>
<p>===</p>
<p><img src="https://velog.velcdn.com/images/youngkyoo_kim/post/bf8c5a8c-4257-4dbd-a0a4-c628d8f5d31b/image.png" alt=""></p>
<p>각 레이어를 클릭하면 세부 질문을 바로 이어서 보낼 수 있도록 인터랙티브 다이어그램으로 만들었습니다.각 박스를 클릭하면 해당 레이어에 대한 심층 질문으로 바로 이어집니다. 이제 각 레이어별 구조를 설명드리겠습니다.</p>
<hr>
<h2 id="1-bgp--ecmp-유입--외부-경로-결정">1. BGP / ECMP 유입 — 외부 경로 결정</h2>
<p>ClusterMesh + BGP 환경에서는 각 노드가 자신의 Pod CIDR을 L3 스위치에 BGP로 광고합니다. 문제는 ECMP(Equal-Cost Multi-Path)가 활성화된 경우, <strong>같은 플로우의 SYN과 ACK가 서로 다른 경로로 들어올 수 있다</strong>는 것입니다. 스위치 레벨의 ECMP 해싱이 5-tuple 기반이면 안전하지만, 구현에 따라 패킷 단위로 분산되기도 합니다.</p>
<h2 id="2-nic-하드웨어--rss-및-오프로딩">2. NIC 하드웨어 — RSS 및 오프로딩</h2>
<p><code>ens5f0np0 / ens5f1np1</code>에서 받은 패킷은 RSS(Receive Side Scaling)에 의해 특정 CPU 큐로 배분됩니다. GRO(Generic Receive Offload)가 활성화되어 있으면 작은 패킷을 합쳐 커널로 올리는데, SYN/ACK 같은 소형 제어 패킷은 이 과정에서 타이밍이 미묘하게 영향받을 수 있습니다.</p>
<h2 id="3-bond1-드라이버--tcpdump가-안-보이는-핵심-이유">3. Bond1 드라이버 — tcpdump가 안 보이는 핵심 이유</h2>
<p><strong>active-backup</strong> 모드라면 RX는 항상 active 슬레이브(예: <code>ens5f1np1</code>) 하나에서만 올라오고, <code>bond1</code> 인터페이스에는 나타나지 않는 경우가 있습니다. <strong>LACP(802.3ad)</strong> 모드에서는 수신이 특정 슬레이브에 고정되므로, <code>tcpdump -i bond1</code> 대신 <code>tcpdump -i ens5f1np1</code>로 캡처해야 패킷이 보입니다.</p>
<h2 id="4-cilium-ebpf-tc-hook--이-레이어는-정상-통과">4. Cilium eBPF (TC hook) — 이 레이어는 정상 통과</h2>
<p>Cilium은 <code>tc</code> ingress에 eBPF 프로그램을 붙여 패킷을 처리합니다. Native routing 모드에서는 VXLAN 없이 직접 커널 라우팅 테이블을 활용합니다. <code>cilium monitor</code>에 drop이 찍히지 않는다는 것은 <strong>Cilium의 보안 정책, LB 로직, conntrack 레벨에서는 모두 허용됐다</strong>는 의미입니다. 즉, 문제는 이 레이어 아래에 있습니다.</p>
<h2 id="5-netfilter-prerouting-→-local_in">5. Netfilter (PREROUTING → LOCAL_IN)</h2>
<p><code>nf_hook_slow</code>가 호출되며 체크섬 검사(<code>nf_checksum</code>)와 등록된 netfilter 룰을 통과합니다. pwru 트레이스상 이 단계에서 DROP 흔적이 없으므로 방화벽/iptables 문제는 배제됩니다.</p>
<h2 id="6-ip-라우팅--rp_filter--⚠️-잠재적-드랍-지점">6. IP 라우팅 + rp_filter — ⚠️ 잠재적 드랍 지점</h2>
<p><code>fib_validate_source</code>가 호출되며 여기서 <code>rp_filter</code>(Reverse Path Filter)가 작동합니다. <strong>ECMP 비대칭 라우팅</strong> 상황에서 SYN-ACK는 <code>bond1</code>을 통해 나갔는데 클라이언트의 ACK가 다른 슬레이브나 다른 경로로 들어오면, rp_filter Strict 모드(<code>sysctl net.ipv4.conf.all.rp_filter=1</code>)가 이를 스푸핑으로 간주해 드랍할 수 있습니다. 단, pwru 트레이스에서 이 단계를 통과했다면 현재 케이스의 직접 원인은 아닙니다.</p>
<h2 id="7--8-tcp_v4_rcv-→-tcp_check_req-→-drop--실제-드랍-지점">7 &amp; 8. tcp_v4_rcv → tcp_check_req → DROP — 실제 드랍 지점</h2>
<p><code>tcp_v4_rcv</code>가 패킷을 받으면 <code>tcp_v4_fill_cb</code>로 TCP 제어 블록을 채운 뒤, LISTEN 소켓이 관리하는 <code>request_sock</code>과 ACK를 대조하는 <strong><code>tcp_check_req</code></strong>로 진입합니다. 여기서 드랍되는 두 가지 핵심 경로는 다음과 같습니다.</p>
<p><strong>경로 A — SYN Cookie 검증 실패</strong>: SYN 큐가 꽉 찼을 때 커널이 SYN cookie를 발행하는데, 클라이언트의 최종 ACK에 포함된 쿠키 값이 서버의 재계산 결과와 불일치하면 드랍됩니다. <code>nstat -az | grep SyncookiesFailed</code> 카운터로 확인 가능합니다.</p>
<p><strong>경로 B — request_sock 타임아웃 후 ACK 도착</strong>: 서버가 SYN-ACK를 보내고 <code>request_sock</code>(반만 열린 연결 대기 구조)에서 클라이언트 ACK를 기다리는데, L3 스위치 구간의 순간적 지연으로 ACK가 늦게 도착하면 <code>request_sock</code>이 이미 expire되어 &quot;알 수 없는 ACK&quot;로 처리, 드랍됩니다. <code>nstat -az | grep TcpExtListenDrops</code>로 확인합니다.</p>
<hr>
<p>이 두 경로 중 어느 쪽인지 확정하려면 <code>nstat -az | grep -E &#39;SyncookiesFailed|ListenDrops|TCPMinisocksDrop&#39;</code> 결과에서 어떤 카운터가 올라가는지 공유해 주시면 바로 다음 단계를 짚어드릴 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A21a]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A21a</link>
            <guid>https://velog.io/@youngkyoo_kim/26A21a</guid>
            <pubDate>Mon, 20 Apr 2026 20:35:30 GMT</pubDate>
            <description><![CDATA[<p>제공해주신 <code>pwru</code> 트레이스 분석 결과와 상황을 종합해 볼 때, 이전 모델의 분석은 <strong>커널 내부의 흐름(Function Call Path) 관점에서는 정확</strong>하지만, 인프라 운영 측면에서는 <strong>&#39;왜&#39; 이런 일이 발생하는지에 대한 근본 원인(Root Cause)</strong>을 더 좁힐 필요가 있습니다.</p>
<p>플랫폼 엔지니어링 리드로서 대규모 클러스터를 운영하시는 상황임을 고려하여, 실무적인 관점에서 다음과 같이 판단하고 추가 조언을 드립니다.</p>
<hr>
<h2 id="1-분석-결과에-대한-제-판단">1. 분석 결과에 대한 제 판단</h2>
<p>이전 모델의 분석 중 <strong><code>tcp_check_req</code>에서 드랍</strong>된다는 점이 핵심입니다. 이 단계는 TCP Handshake 과정에서 <code>SYN</code>을 받고 <code>SYN-ACK</code>을 보낸 후, 클라이언트로부터 오는 <strong>마지막 <code>ACK</code>를 검증</strong>하는 단계입니다. 여기서 드랍된다는 것은 서버가 &quot;나는 이 ACK를 받을 준비가 안 됐거나, 기대하던 ACK가 아니다&quot;라고 판단한 것입니다.</p>
<h3 id="왜-tcpdump에는-안-찍힐까-중요">왜 <code>tcpdump</code>에는 안 찍힐까? (중요)</h3>
<p><code>pwru</code>에는 찍히는데 <code>tcpdump</code>에 안 찍히는 이유는 크게 두 가지입니다.</p>
<ul>
<li><strong>Capture Point의 차이:</strong> <code>tcpdump</code>(libpcap)는 보통 NIC 드라이버 레벨에서 패킷을 가로챕니다. 반면 <code>pwru</code>는 커널 함수에 직접 훅을 겁니다. 패킷이 물리 NIC로 들어왔더라도 <strong>Bonding, VLAN, 혹은 Cilium의 eBPF 프로그램</strong>이 패킷을 처리하는 방식에 따라 <code>tcpdump</code>가 바라보는 인터페이스에는 패킷이 통과하지 않을 수 있습니다.</li>
<li><strong>Cilium의 BPF Host Routing:</strong> Cilium이 <code>bpf-host-routing</code>을 사용 중이라면 일반적인 네트워킹 스택을 우회(Bypass)하여 스택의 더 깊은 곳으로 바로 패킷을 주입할 수 있습니다.</li>
</ul>
<hr>
<h2 id="2-예상되는-근본-원인-best-guess">2. 예상되는 근본 원인 (Best Guess)</h2>
<p>단순히 &quot;상태 불일치&quot;를 넘어, 실제 환경에서 흔히 발생하는 시나리오는 다음과 같습니다.</p>
<h3 id="①-mtu-및-패킷-파편화-fragmentation-문제">① MTU 및 패킷 파편화 (Fragmentation) 문제</h3>
<p>SSH 연결 시 <code>SYN</code>, <code>SYN-ACK</code>까지는 크기가 작아 잘 넘어가다가, 세션 수립 직후나 특정 데이터가 실린 <code>ACK</code> 패킷이 <strong>MTU 제한</strong>으로 인해 조각나거나 드랍될 때 <code>tcp_check_req</code>에서 문제가 발생할 수 있습니다.</p>
<ul>
<li><strong>Cilium 환경 특이점:</strong> VXLAN이나 Geneve 터널링을 사용 중이라면 오버헤드(50 bytes+) 때문에 실제 MTU가 1450 이하로 설정되어야 합니다.</li>
</ul>
<h3 id="②-클러스터-외부-load-balancer--proxy의-idle-timeout">② 클러스터 외부 Load Balancer / Proxy의 Idle Timeout</h3>
<p>간헐적으로 발생한다면, 서버(수신측)의 <code>request_sock</code>(SYN을 받고 기다리는 소켓)이 타임아웃되어 삭제된 후에 클라이언트의 <code>ACK</code>가 도착했을 가능성이 큽니다.</p>
<ul>
<li>네트워크 지연(Latency)이 순간적으로 튀거나, 중간 경로(L4, Firewall)에서 세션을 먼저 끊어버린 경우입니다.</li>
</ul>
<h3 id="③-cilium의-conntrack-table-full-또는-충돌">③ Cilium의 Conntrack Table Full 또는 충돌</h3>
<p>Cilium은 자체적인 eBPF 기반 conntrack 테이블을 관리합니다. 커널의 <code>tcp_check_req</code>와 Cilium의 상태 관리가 어긋날 때(예: 비대칭 라우팅) 드랍이 발생할 수 있습니다.</p>
<hr>
<h2 id="3-정확한-진단을-위해-더-필요한-정보">3. 정확한 진단을 위해 더 필요한 정보</h2>
<p>더 명확한 답을 드리기 위해 다음 정보가 필요합니다.</p>
<ol>
<li><strong>통신 경로:</strong> 클라이언트와 서버가 같은 K8s 노드에 있나요? 아니면 외부에서 노드로 들어오는 상황인가요? (LB 통과 여부)</li>
<li><strong>Cilium 설정:</strong> <code>native-routing</code>인가요, 아니면 <code>tunnel(VXLAN/Geneve)</code> 모드인가요?</li>
<li><strong>드랍된 패킷의 상세 정보:</strong> <code>pwru</code> 실행 시 <code>-sh</code> (show help) 옵션 등을 활용해 드랍된 패킷의 <strong>TCP Flags(ACK인지 RST인지)</strong>와 <strong>Sequence Number</strong>를 확인할 수 있나요?</li>
<li><strong>수신측 노드의 자원 상황:</strong> 드랍 발생 시점에 해당 노드의 CPU 사용량(특히 SoftIRQ)이 급증하지는 않았나요?</li>
</ol>
<hr>
<h2 id="4-추천하는-후속-조치-action-items">4. 추천하는 후속 조치 (Action Items)</h2>
<p>이전 답변에서 제안한 <code>cat /proc/net/bonding/bond1</code> 확인 외에 다음을 먼저 시도해 보세요.</p>
<ol>
<li><strong>인터페이스 전체 캡처:</strong>
<code>tcpdump -i any port 22 -nnvv</code>로 캡처해 보시고, 그래도 안 나온다면 <code>eth0</code>, <code>eth1</code> 등 물리 슬레이브 인터페이스 각각을 지정해서 다시 확인해 보세요.</li>
<li><strong>Cilium 상태 확인:</strong><pre><code class="language-bash">cilium monitor --type drop
# 혹은 특정 IP에 대해
cilium monitor --related-to &lt;클라이언트_IP&gt;</code></pre>
Cilium 레벨에서 <code>policy denied</code>나 <code>ct_trunc</code> 등의 드랍 사유가 찍히는지 확인해야 합니다.</li>
<li><strong>커널 메트릭 확인:</strong>
<code>netstat -s | grep -i drop</code> 혹은 <code>nstat -az | grep -i TcpExtListenDrops</code> 명령어를 통해 커널 스택 수준의 드랍 카운트가 올라가는지 보십시오.</li>
</ol>
<p><strong>제 판단은 이렇습니다:</strong> &quot;이것은 단순한 설정 오류라기보다, <strong>네트워크 경로상의 MTU 불일치</strong>나 <strong>비대칭 라우팅(Asymmetric Routing)</strong>으로 인해 커널이 유효하지 않은 ACK를 받았다고 판단했을 확률이 90% 이상입니다.&quot;</p>
<p>추가 정보를 주시면 더 구체적으로 디버깅해 드릴 수 있습니다. 어떻게 진행해 볼까요?</p>
<p>===</p>
<p>추가 정보 감사합니다. <strong>Native Routing</strong>을 사용 중이고 <code>Cilium monitor drop</code>에 아무것도 찍히지 않는다면, 이는 Cilium의 BPF Datapath(보안 정책, 라우팅 로직 등) 문제가 아니라 <strong>리눅스 커널의 표준 TCP 스택 수준에서 패킷이 거부</strong>되고 있음을 강력하게 시사합니다.</p>
<p><code>pwru</code> 트레이스에서 <strong><code>tcp_check_req</code></strong> 단계에서 드랍이 발생한다는 것은, 커널이 TCP Handshake의 마지막 단계인 <code>ACK</code> 패킷을 받았으나 이를 유효한 연결 요청으로 인정하지 않았다는 뜻입니다. L3 스위치만 있는 환경에서 발생할 수 있는 구체적인 원인과 점검 포인트를 정리해 드립니다.</p>
<h3 id="1-cilium-monitor에-왜-안-찍힐까">1. <code>Cilium monitor</code>에 왜 안 찍힐까?</h3>
<p>Cilium Monitor는 eBPF 프로그램이 명시적으로 <code>drop</code> 결정을 내릴 때(예: Policy Denied, LB 관련 오류 등) 이벤트를 발생시킵니다. 하지만 현재 상황은 패킷이 Cilium의 BPF 로직을 무사히 통과하여 <strong>커널의 TCP 서브시스템(<code>tcp_v4_rcv</code>)으로 전달된 후</strong>, 커널 함수 내부 검증 로직에서 드랍된 것이기 때문에 Cilium 레벨의 로그에는 남지 않는 것이 정상입니다.</p>
<hr>
<h3 id="2-tcp_check_req-드랍의-핵심-원인-분석">2. <code>tcp_check_req</code> 드랍의 핵심 원인 분석</h3>
<p>이 단계에서 패킷이 버려지는 가장 흔한 이유는 다음과 같습니다.</p>
<h4 id="①-syn-cookie-검증-실패-netipv4tcp_syncookies">① SYN Cookie 검증 실패 (<code>net.ipv4.tcp_syncookies</code>)</h4>
<p>수신측 노드에 순간적으로 많은 연결 요청이 들어와 <code>SYN Queue</code>가 가득 찼을 때, 커널은 SYN Cookie를 발행합니다. 클라이언트가 보낸 <code>ACK</code> 패킷에 포함된 쿠키 값이 서버의 계산 결과와 일치하지 않으면 <code>tcp_check_req</code>에서 드랍됩니다.</p>
<ul>
<li><strong>확인 방법:</strong> <code>sysctl net.ipv4.tcp_syncookies</code> 값이 <code>1</code>인지 확인하고, <code>nstat -az | grep -i SyncookiesFailed</code> 카운트가 올라가는지 보십시오.</li>
</ul>
<h4 id="②-request-sock-타임아웃-listendrops">② Request Sock 타임아웃 (ListenDrops)</h4>
<p>서버가 <code>SYN</code>을 받고 <code>SYN-ACK</code>를 보낸 뒤 클라이언트의 <code>ACK</code>를 기다리는 동안 사용하는 것이 <code>request_sock</code>입니다. 네트워크 지연(L3 스위치 구간의 일시적 혼잡 등)으로 인해 클라이언트의 <code>ACK</code>가 너무 늦게 도착하여 서버 쪽의 대기 시간이 만료되면, 커널은 이 패킷을 &quot;알 수 없는 연결의 ACK&quot;로 취급하여 버립니다.</p>
<ul>
<li><strong>확인 방법:</strong> <code>nstat -az | grep -i TcpExtListenDrops</code> 또는 <code>TcpExtTCPMinisocksDrop</code> 카운트를 확인하십시오.</li>
</ul>
<h4 id="③-비대칭-라우팅-asymmetric-routing-및-rp_filter">③ 비대칭 라우팅 (Asymmetric Routing) 및 <code>rp_filter</code></h4>
<p>Native Routing 환경에서 L3 스위치가 ECMP 등으로 인해 나가는 경로와 들어오는 경로를 다르게 잡을 수 있습니다. 만약 패킷이 서버에 들어올 때 예상치 못한 인터페이스(예: bond1이 아닌 다른 인터페이스)로 들어오면 커널의 <code>rp_filter</code>가 작동하여 드랍될 수 있습니다.</p>
<ul>
<li><strong>확인 방법:</strong> <code>sysctl -a | grep rp_filter</code> 값이 <code>1(Strict)</code>인지 확인해 보십시오. 테스트를 위해 임시로 <code>0</code>으로 설정하고 현상이 재발하는지 확인해 볼 수 있습니다.</li>
</ul>
<hr>
<h3 id="3-추가-진단을-위한-가이드">3. 추가 진단을 위한 가이드</h3>
<p>현재 상황에서 문제를 확정 짓기 위해 다음을 실행해 보시길 권장합니다.</p>
<ol>
<li><p><strong>커널 정밀 통계 확인 (가장 추천)</strong>
문제가 발생한 직후 다음 명령어를 실행하여 어떤 카운터가 올라가는지 확인하십시오.</p>
<pre><code class="language-bash">nstat -az | grep -E &#39;TcpExtListenDrops|TcpExtTCPMinisocksDrop|Syncookies|TcpExtTCPACKSkipped&#39;</code></pre>
</li>
<li><p><strong>물리 인터페이스 직접 캡처</strong>
<code>tcpdump -i any</code> 보다는 물리 NIC(예: <code>ens...</code>)를 각각 지정해서 캡처해 보십시오. L3 스위치에서 오는 패킷이 본딩된 슬레이브 중 어느 쪽으로 들어오는지, 그리고 그 패킷의 <strong>Sequence Number</strong>와 <strong>ACK Number</strong>가 서버가 보낸 <code>SYN-ACK</code>와 일치하는지 대조해야 합니다.</p>
</li>
<li><p><strong>MTU 확인</strong>
Native Routing이라도 하단 물리 망의 MTU가 1500 미만으로 설정된 구간이 있다면, SSH 인증 과정에서 큰 패킷이 오갈 때 <code>tcp_check_req</code> 이후 단계에서 문제가 될 수 있습니다. (다만, 현재 <code>pwru</code>상 드랍 지점은 Handshake 단계에 더 가깝습니다.)</p>
</li>
</ol>
<p><strong>결론적으로 제 판단은:</strong>
방화벽이 없는 Native Routing 환경이므로, <strong>&quot;L3 스위치 구간의 일시적 지연으로 인한 SYN 대기 소켓(request_sock) 타임아웃&quot;</strong> 또는 <strong>&quot;커널의 SYN Cookie 오작동&quot;</strong>일 가능성이 가장 높습니다. </p>
<p>위의 <code>nstat</code> 결과값 중 카운트가 유독 높은 항목이 있다면 알려주세요. 바로 다음 단계를 짚어드리겠습니다.</p>
<p>===</p>
<hr>
<h3 id="1-텍스트"><strong>1 텍스트</strong></h3>
<p>이번 트레이스는 같은 플로우(10.111.43.21:11180 -&gt; 10.111.43.38:22)에 대해 “PREROUTING/LOCAL_IN(넷필터) -&gt; 라우팅/소스검증 -&gt; 로컬 딜리버리 -&gt; RAW 소켓 검사 -&gt; TCP 수신 -&gt; 요청 검증(tcp_check_req) -&gt; 드롭”까지의 세부 경로를 보여줍니다. 핵심은 마지막에 TCP 계층에서 해당 패킷이 처리 중 “요청 검증 단계에서 드롭”되었다는 점입니다.</p>
<p><strong>핵심 흐름 해석</strong></p>
<ul>
<li><strong>nf_hook_slow -&gt; nf_checksum/nf_ip_checksum</strong><ul>
<li>넷필터 훅(주로 PREROUTING/LOCAL_IN)에서 체크섬 검사를 포함한 기본 검증을 수행. 여기서 DROP된 흔적은 보이지 않습니다.</li>
</ul>
</li>
<li><strong>tcp_v4_early_demux -&gt; ip_route_input_{noref,slow} -&gt; fib_validate_source/_fib_validate_source</strong><ul>
<li>라우팅 결정 및 소스 유효성 검증(RP 필터 경로). 통과했기 때문에 이후 로컬 딜리버리로 진행됩니다.</li>
</ul>
</li>
<li><strong>ip_local_deliver -&gt; ip_local_deliver_finish -&gt; ip_protocol_deliver_rcu</strong><ul>
<li>로컬 호스트(10.111.43.38) 대상 패킷으로 확정되어 상위(TCP)로 전달.</li>
</ul>
</li>
<li><strong>raw_local_deliver/raw_v4_input</strong><ul>
<li>RAW 소켓이 붙어 있다면 먼저 전달. 이어서 TCP로 진입.</li>
</ul>
</li>
<li><strong>sk_filter_trim_cap -&gt; security_sock_rcv_skb -&gt; selinux_socket_sock_rcv_skb -&gt; bpf_lsm_socket_sock_rcv_skb</strong><ul>
<li>소켓 레벨의 BPF/LSM/SELinux 훅 및 소켓 필터가 실행. 여기서 거부 흔적은 보이지 않습니다.</li>
</ul>
</li>
<li><strong>tcp_v4_rcv -&gt; tcp_v4_fill_cb -&gt; tcp_check_req</strong><ul>
<li>TCP 제어블록 설정 후, “요청 검증” 경로로 진입. 이는 보통 다음 두 경우에 해당:</li>
<li>LISTEN 중인 소켓이 받은 SYN에 대해 생성된 request_sock에 대해 후속 패킷(주로 ACK)을 검증할 때</li>
<li>SYN cookies/백로그/상태 일치 여부 등 조건을 확인</li>
</ul>
</li>
<li><strong>sk_skb_reason_drop(SKB_DROP_REASON_NOT_SPECIFIED) -&gt; skb_release_* -&gt; kfree_skbmem</strong><ul>
<li>검증 단계에서 조건 불일치 혹은 처리 불가로 판단되어 TCP가 skb를 드롭하고 메모리를 해제함을 의미합니다.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-텍스트"><strong>2 텍스트</strong></h3>
<p><strong>무엇이 드롭을 유발했을 가능성이 큰가?</strong></p>
<ul>
<li><strong>tcp_check_req 단계에서의 드롭 원인 후보:</strong><ul>
<li>예상치 못한 ACK/RST: 기존 요청(request_sock)과 매칭되지 않는 ACK(예: 초과 지연, 틀린 ISN/윈도우, 이미 타임아웃 등)</li>
<li>SYN cookie 검증 실패: SYN cookies 사용 시, 이후 ACK가 쿠키 조건과 맞지 않으면 드롭</li>
<li>백로그/큐 상태 문제로 요청이 소멸된 뒤 도착한 후속 패킷(ACK) -&gt; 매칭 실패로 드롭</li>
<li>중복/재전송 상황에서 윈도우/상태 불일치</li>
</ul>
</li>
<li><strong>킬러 단서:</strong><ul>
<li>이전에 60바이트(SYN+옵션) 패킷이 보였고, 여기서는 20바이트(옵션 없는) 패킷이 관찰됩니다. 20바이트는 “순수 ACK” 혹은 “RST”일 확률이 큽니다. 이 순수 ACK가 기존 요청과 맞지 않으면 tcp_check_req에서 드롭되는 전형적인 패턴과 부합합니다.</li>
</ul>
</li>
</ul>
<p><strong>중요한 점</strong></p>
<ul>
<li>넷필터(nf_hook_slow)나 SELinux/BPF 훅에서 DROP된 흔적은 없고, 최종적으로 TCP 레이어에서 드롭되었습니다.</li>
<li>즉, 방화벽/SELinux 차단이 아니라 “TCP 상태/요청 매칭 불일치” 계열의 이유로 커널 TCP가 폐기한 것으로 해석하는 것이 타당합니다.</li>
<li>cpuidle_enter/do_idle 계열 콜스택 항목들은 인터럽트/소프트IRQ 진입 전/후의 CPU 아이들 경로로, 패킷 처리 자체의 성공/실패와 직접적인 상관은 없습니다.</li>
</ul>
<p><strong>진단을 확정하려면 필요한 것</strong></p>
<ul>
<li><strong>패킷 플래그/시퀀스 확인</strong><ul>
<li>tcpdump/pcap으로 해당 시간대 플래그와 시퀀스/ACK 번호를 확인하면 원인 판별이 빨라집니다.</li>
<li>예: <code>tcpdump -i bond1 -nnvv &#39;host 10.111.43.21 and port 22&#39;</code></li>
<li>확인 포인트: SYN -&gt; SYN-ACK -&gt; ACK의 3차 핸드셰이크가 완결되는지, ACK 번호가 합당한지, RST가 오는지.</li>
</ul>
</li>
<li><strong>SYN cookies/백로그 상태</strong><ul>
<li>sysctl net.ipv4.tcp_syncookies 값 확인(1이면 활성).</li>
<li>백로그: <code>ss -lnt sport = :22</code>로 큐 상태, somaxconn, tcp_max_syn_backlog 설정 확인.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="3-텍스트"><strong>3 텍스트</strong></h3>
<ul>
<li><strong>conntrack/상태 관찰</strong><ul>
<li><code>conntrack -L | grep &#39;10.111.43.21.*22&#39;</code>로 세션 상태를 살펴보고, SYN-RECV/SYN-SENT/ESTABLISHED로 전이되는지 확인.</li>
</ul>
</li>
<li><strong>서버/클라이언트 재전송/지연</strong><ul>
<li>RTT가 큰 환경이나 패킷 손실이 있을 경우, request_sock 타임아웃 이후 ACK 도착 -&gt; 드롭 시나리오가 발생할 수 있습니다. 인터페이스/링크 오류 카운터(ethtool -S)도 점검.</li>
</ul>
</li>
</ul>
<p><strong>정리</strong></p>
<ul>
<li>이 트레이스 상의 패킷은 넷필터·SELinux를 통과해 TCP까지 왔지만, tcp_check_req 단계에서 조건 불일치로 드롭되었습니다.</li>
<li>가장 가능성 높은 원인은 “예상과 맞지 않는 ACK(또는 상태 불일치)”이고, SYN cookie/백로그/타이밍 문제와 연관될 수 있습니다.</li>
<li>패킷 캡처로 플래그/시퀀스, 시스템의 syncookies/백로그 설정, 그리고 네트워크 품질을 함께 확인하면 원인을 특정할 수 있습니다. 필요하시면 tcpdump 결과 일부(특히 SYN/SYN-ACK/ACK 시퀀스)와 <code>sysctl -a | egrep &#39;tcp_syncookies|somaxconn|tcp_max_syn_backlog&#39;</code> 출력, <code>ss -lnt sport = :22</code> 결과를 공유해 주세요.</li>
</ul>
<hr>
<h3 id="4-텍스트"><strong>4 텍스트</strong></h3>
<p><strong>상황 정리</strong></p>
<ul>
<li>커널 트레이스상으로는 해당 패킷이 NIC -&gt; 본딩 -&gt; Netfilter(PREROUTING/LOCAL_IN) -&gt; 라우팅/소스검증 -&gt; TCP로 올라온 뒤, tcp_check_req 단계에서 드롭되었습니다.</li>
<li>그런데 수신측에서 tcpdump가 “아무 것도 안 찍힌다”면, 보통은 캡처 인터페이스/네임스페이스/필터/오프로드 등의 이유로 캡처 지점에서 패킷을 못 보고 있을 가능성이 큽니다(실제로 패킷이 없는 상황이 아니라면).</li>
</ul>
<p><strong>왜 tcpdump가 안 보일 수 있나(가능성)</strong>
<strong>1) 본딩 인터페이스에서 캡처함</strong></p>
<ul>
<li>리눅스 본딩(특히 active-backup 등) 환경에서는 RX 패킷이 물리 슬레이브에서 처리되고, 가상 인터페이스(bond1)에서 tcpdump가 잘 안 보이는 경우가 흔합니다.</li>
<li>트레이스에 ens5f1np1:7와 bond1:2가 교차로 등장하므로, 실제 수신은 ens5f1np1(슬레이브)에서 이루어졌을 가능성이 큽니다.</li>
</ul>
<p><strong>2) 잘못된 캡처 인터페이스/네임스페이스</strong></p>
<ul>
<li>컨테이너/네트워크 네임스페이스에서 tcpdump를 실행하면 호스트 NIC 트래픽이 안 보입니다.</li>
<li>VLAN이 사용되는데 bond1.&lt;vlan&gt;이 아닌 bond1에서 캡처하면 놓칠 수 있습니다.</li>
</ul>
<p><strong>3) 필터/오프로드 영향</strong></p>
<ul>
<li>VLAN 태그가 있는 경우 BPF 필터가 매칭을 놓칠 수 있습니다.</li>
<li>GRO/LRO 등으로 인해 세그먼트가 합쳐져 보이거나 체크섬 오프로딩이 표시를 왜곡할 수 있지만, SYN 자체가 “아예 안 보이는” 현상을 설명하진 못합니다. 다만 진단 시 혼동 요소이므로 필요시 비활성화 권고.</li>
</ul>
<p><strong>먼저 확인할 것</strong></p>
<ul>
<li><p><strong>현재 활성 슬레이브 확인:</strong></p>
<pre><code class="language-bash">cat /proc/net/bonding/bond1</code></pre>
</li>
<li><p><strong>네임스페이스 확인(호스트에서 실행 중인지):</strong></p>
<pre><code class="language-bash">ip netns identify $$</code></pre>
</li>
<li><p><strong>VLAN 사용 여부:</strong></p>
<pre><code class="language-bash">ip -d link show bond1</code></pre>
</li>
</ul>
<p><strong>권장 캡처 방법(하나씩 시도)</strong>
<strong>1) 슬레이브 NIC에서 캡처</strong></p>
<pre><code class="language-bash">sudo tcpdump -i ens5f1np1 -nnvv -Q in &#39;host 10.111.43.21 and port 22&#39;</code></pre>
<ul>
<li>active-backup이면 활성 슬레이브에서만 보입니다. balance-xor/rr 등 LACP 모드에서도 RX는 특정 슬레이브로만 들어옵니다.</li>
</ul>
<p><strong>2) 모든 인터페이스에서 캡처(네임스페이스/인터페이스 문제 회피)</strong></p>
<pre><code class="language-bash">sudo tcpdump -i any -nnvv &#39;host 10.111.43.21 and port 22&#39;</code></pre>
<p><strong>3) VLAN 가능성 고려(캡처 출력에 VLAN 헤더 표시)</strong></p>
<pre><code class="language-bash">sudo tcpdump -i ens5f1np1 -nnvv -e -Q in &#39;vlan and host 10.111.43.21 and port 22&#39;</code></pre>
<p><strong>4) 패킷 손실/오프로드 혼선 최소화(임시)</strong></p>
<pre><code class="language-bash"># 필요 시 일시적으로만 비활성화(테스트 후 원복)
sudo ethtool -K ens5f1np1 gro off lro off gso off tso off rxhash off</code></pre>
<p><strong>5) 캡처를 파일로 저장해 사후 분석</strong></p>
<pre><code class="language-bash">sudo tcpdump -i ens5f1np1 -nn -s 0 -U -w /tmp/ssh_rx.pcap &#39;host 10.111.43.21 and port 22&#39;</code></pre>
<p><strong>보조 진단</strong></p>
<ul>
<li><p><strong>NIC 카운터 증가 여부(수신이 실제 들어오는지):</strong></p>
<pre><code class="language-bash">ethtool -S ens5f1np1 | egrep &#39;rx_.*pack|rx_.*err|rx_.*drop&#39;</code></pre>
</li>
<li><p><strong>커널 드롭 위치 추적(드롭워치):</strong></p>
<pre><code class="language-bash">sudo dropwatch -l kas</code></pre>
</li>
<li><p><strong>conntrack/핸드세이크 상태:</strong></p>
<pre><code class="language-bash">sudo conntrack -L | grep &#39;10.111.43.21.*dport=22&#39;</code></pre>
</li>
</ul>
<p><strong>해석 가이드</strong></p>
<ul>
<li>위 캡처 중 하나라도 SYN 또는 ACK가 보이면, 커널 트레이스와 일치합니다. 이후 SYN -&gt; SYN-ACK -&gt; ACK 흐름이 정상인지 확인하세요.</li>
<li>여전히 아무 것도 안 보인다면:<ul>
<li>캡처를 실행하는 위치(호스트 vs 컨테이너/네임스페이스), 인터페이스 선택(특히 슬레이브 NIC), VLAN 인터페이스 여부가 가장 유력합니다.</li>
<li>본딩 모드/슬레이브 상태와 일치하는 곳에서 캡처하는지 재확인해 주세요.
필요하시면 현재 본딩 설정(<code>cat /proc/net/bonding/bond1</code>)과 실행 중인 tcpdump 명령 줄을 알려주세요. 환경에 맞춰 캡처 지점을 정확히 짚어 드리겠습니다.</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A10d]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A10d</link>
            <guid>https://velog.io/@youngkyoo_kim/26A10d</guid>
            <pubDate>Fri, 10 Apr 2026 05:19:54 GMT</pubDate>
            <description><![CDATA[<p>```python
#!/usr/bin/env python3
&quot;&quot;&quot;
AIStor iNVENTORY Analyzer  (원격 직접 읽기 버전)
=================================================
MinIO AIStor 버킷에 쌓인 iNVENTORY 결과를
로컬 복사 없이 Python에서 직접 스트리밍하여 분석합니다.</p>
<p>저장 경로 구조:
  <DEST_BUCKET>/<PREFIX>/<SOURCE_BUCKET>/<INVENTORY_ID>/<TIMESTAMP>/
      manifest.json
      files/
          file-001.parquet
          file-002.parquet
          ...</p>
<p>parquet 컬럼:
  Bucket, Key, SequenceNumber, Size, LastModifiedDate</p>
<p>사용법:</p>
<h1 id="기본-최신-timestamp-자동-선택">기본 (최신 timestamp 자동 선택)</h1>
<p>  python aistor_inventory_analyzer.py \
      --endpoint play.min.io:9000 \
      --access-key Q3AM3UQ867SPQQA43P2F \
      --secret-key zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG \
      --dest-bucket inventory-reports \
      --source-bucket devops-test \
      --inventory-id my-job-id \
      --depth 2</p>
<h1 id="env-파일-또는-환경변수로-인증-정보-관리-권장">.env 파일 또는 환경변수로 인증 정보 관리 (권장)</h1>
<p>  export AISTOR_ENDPOINT=play.min.io:9000
  export AISTOR_ACCESS_KEY=...
  export AISTOR_SECRET_KEY=...
  python aistor_inventory_analyzer.py \
      --dest-bucket inventory-reports \
      --source-bucket devops-test \
      --inventory-id my-job-id \
      --depth 2</p>
<h1 id="특정-timestamp-지정-생략-시-최신-자동-선택">특정 timestamp 지정 (생략 시 최신 자동 선택)</h1>
<p>  python aistor_inventory_analyzer.py ... --timestamp 2025-01-15T03-00Z</p>
<h1 id="구조-확인만-분석-없이-어떤-jobtimestamp가-있는지-확인">구조 확인만 (분석 없이 어떤 job/timestamp가 있는지 확인)</h1>
<p>  python aistor_inventory_analyzer.py ... --list-jobs
  python aistor_inventory_analyzer.py ... --info-only
&quot;&quot;&quot;</p>
<p>import argparse
import io
import json
import os
import sys</p>
<p>try:
    import pandas as pd
    import pyarrow.parquet as pq
    from minio import Minio
    from minio.error import S3Error
except ImportError as e:
    print(f&quot;[ERROR] 필수 패키지 부족: {e}&quot;)
    print(&quot;  pip install minio pyarrow pandas tabulate&quot;)
    sys.exit(1)</p>
<p>try:
    from tabulate import tabulate
    HAS_TABULATE = True
except ImportError:
    HAS_TABULATE = False</p>
<h1 id="──────────────────────────────────────────────────────────">──────────────────────────────────────────────────────────</h1>
<h1 id="유틸">유틸</h1>
<h1 id="──────────────────────────────────────────────────────────-1">──────────────────────────────────────────────────────────</h1>
<p>def human_size(n: float) -&gt; str:
    if pd.isna(n) or n == 0:
        return &quot;0 B&quot;
    for unit in (&quot;B&quot;, &quot;KB&quot;, &quot;MB&quot;, &quot;GB&quot;, &quot;TB&quot;, &quot;PB&quot;):
        if abs(n) &lt; 1024.0:
            return f&quot;{n:,.2f} {unit}&quot;
        n /= 1024.0
    return f&quot;{n:,.2f} EB&quot;</p>
<p>def make_client(endpoint: str, access_key: str, secret_key: str, secure: bool) -&gt; Minio:
    return Minio(
        endpoint,
        access_key=access_key,
        secret_key=secret_key,
        secure=secure,
    )</p>
<h1 id="──────────────────────────────────────────────────────────-2">──────────────────────────────────────────────────────────</h1>
<h1 id="minio-경로-탐색">MinIO 경로 탐색</h1>
<h1 id="──────────────────────────────────────────────────────────-3">──────────────────────────────────────────────────────────</h1>
<p>def list_jobs(client: Minio, dest_bucket: str, prefix: str,
              source_bucket: str) -&gt; dict[str, list[str]]:
    &quot;&quot;&quot;
    dest_bucket 안에서 source_bucket 하위의 inventory job ID와
    각 job의 timestamp 목록을 반환합니다.</p>
<pre><code>탐색 경로: &lt;prefix&gt;/&lt;source_bucket&gt;/&lt;job_id&gt;/&lt;timestamp&gt;/manifest.json
&quot;&quot;&quot;
base = f&quot;{prefix}/{source_bucket}/&quot; if prefix else f&quot;{source_bucket}/&quot;
base = base.lstrip(&quot;/&quot;)

jobs: dict[str, list[str]] = {}
try:
    objects = client.list_objects(dest_bucket, prefix=base, recursive=True)
    for obj in objects:
        key = obj.object_name  # e.g. &quot;reports/devops-test/my-job/2025-01-15T03-00Z/manifest.json&quot;
        if not key.endswith(&quot;manifest.json&quot;):
            continue
        # base 이후 파트: &lt;job_id&gt;/&lt;timestamp&gt;/manifest.json
        rel = key[len(base):]
        parts = rel.split(&quot;/&quot;)
        if len(parts) &gt;= 3:
            job_id, timestamp = parts[0], parts[1]
            jobs.setdefault(job_id, []).append(timestamp)
except S3Error as e:
    sys.exit(f&quot;[ERROR] 버킷 조회 실패: {e}&quot;)

# timestamp 최신순 정렬
for jid in jobs:
    jobs[jid] = sorted(jobs[jid], reverse=True)
return jobs</code></pre><p>def resolve_manifest_key(prefix: str, source_bucket: str,
                          inventory_id: str, timestamp: str) -&gt; str:
    &quot;&quot;&quot;manifest.json의 오브젝트 키 조립&quot;&quot;&quot;
    parts = [p for p in [prefix, source_bucket, inventory_id, timestamp, &quot;manifest.json&quot;] if p]
    return &quot;/&quot;.join(parts)</p>
<p>def read_manifest(client: Minio, dest_bucket: str, manifest_key: str) -&gt; dict:
    &quot;&quot;&quot;manifest.json 읽기&quot;&quot;&quot;
    try:
        resp = client.get_object(dest_bucket, manifest_key)
        data = json.loads(resp.read().decode(&quot;utf-8&quot;))
        resp.close()
        return data
    except S3Error as e:
        sys.exit(f&quot;[ERROR] manifest 읽기 실패 ({manifest_key}): {e}&quot;)</p>
<p>def resolve_parquet_keys(manifest: dict, dest_bucket: str,
                         prefix: str, source_bucket: str,
                         inventory_id: str, timestamp: str) -&gt; list[str]:
    &quot;&quot;&quot;
    manifest의 files 목록에서 parquet 오브젝트 키 목록을 반환.
    manifest의 key 필드가 절대경로면 그대로, 상대경로면 base_path 를 앞에 붙임.
    &quot;&quot;&quot;
    files = manifest.get(&quot;files&quot;, [])
    if not files:
        sys.exit(&quot;[ERROR] manifest에 files 항목이 없습니다.&quot;)</p>
<pre><code>keys = []
base = &quot;/&quot;.join(p for p in [prefix, source_bucket, inventory_id, timestamp, &quot;files&quot;] if p)

for f in files:
    # f 는 {&quot;key&quot;: &quot;...&quot;, &quot;size&quot;: ...} 또는 문자열일 수 있음
    raw_key = f[&quot;key&quot;] if isinstance(f, dict) else str(f)

    # 절대 경로(버킷명 포함) vs 상대 경로 처리
    if raw_key.startswith(dest_bucket + &quot;/&quot;):
        raw_key = raw_key[len(dest_bucket) + 1:]

    # 상대 경로인 경우 base 앞에 붙임
    if not raw_key.startswith(source_bucket) and not raw_key.startswith(prefix or source_bucket):
        obj_key = f&quot;{base}/{raw_key.lstrip(&#39;/&#39;)}&quot;
    else:
        obj_key = raw_key

    keys.append(obj_key)

return keys</code></pre><h1 id="──────────────────────────────────────────────────────────-4">──────────────────────────────────────────────────────────</h1>
<h1 id="원격-parquet-스트리밍-읽기">원격 parquet 스트리밍 읽기</h1>
<h1 id="──────────────────────────────────────────────────────────-5">──────────────────────────────────────────────────────────</h1>
<p>def stream_parquet(client: Minio, dest_bucket: str, object_key: str) -&gt; pd.DataFrame:
    &quot;&quot;&quot;
    MinIO에서 parquet 파일을 메모리로 스트리밍하여 DataFrame 반환.
    256 MB 파일도 청크 없이 pyarrow가 처리 (columnar 포맷 특성상 효율적).
    &quot;&quot;&quot;
    try:
        resp = client.get_object(dest_bucket, object_key)
        buf = io.BytesIO(resp.read())
        resp.close()
    except S3Error as e:
        print(f&quot;  [WARN] 파일 읽기 실패, 건너뜀 ({object_key}): {e}&quot;)
        return pd.DataFrame()</p>
<pre><code>try:
    table = pq.read_table(buf)
    return table.to_pandas()
except Exception as e:
    print(f&quot;  [WARN] parquet 파싱 실패, 건너뜀 ({object_key}): {e}&quot;)
    return pd.DataFrame()</code></pre><p>def load_all_parquets(client: Minio, dest_bucket: str,
                      parquet_keys: list[str]) -&gt; pd.DataFrame:
    &quot;&quot;&quot;모든 parquet 파일을 순차 스트리밍 후 병합&quot;&quot;&quot;
    dfs = []
    total = len(parquet_keys)
    for i, key in enumerate(parquet_keys, 1):
        fname = key.split(&quot;/&quot;)[-1]
        print(f&quot;  [{i}/{total}] {fname} 읽는 중...&quot;, end=&quot; &quot;, flush=True)
        df = stream_parquet(client, dest_bucket, key)
        if not df.empty:
            print(f&quot;{len(df):,} rows&quot;)
            dfs.append(df)
        else:
            print(&quot;(건너뜀)&quot;)</p>
<pre><code>if not dfs:
    sys.exit(&quot;[ERROR] 읽을 수 있는 parquet 파일이 없습니다.&quot;)

result = pd.concat(dfs, ignore_index=True)
print(f&quot;\n[INFO] 총 {len(result):,} rows 로드 완료&quot;)
return result</code></pre><h1 id="──────────────────────────────────────────────────────────-6">──────────────────────────────────────────────────────────</h1>
<h1 id="key-→-prefix-파생">Key → prefix 파생</h1>
<h1 id="──────────────────────────────────────────────────────────-7">──────────────────────────────────────────────────────────</h1>
<p>def extract_prefix_at_depth(key: str, depth: int) -&gt; str:
    &quot;&quot;&quot;
    Key의 디렉토리 경로를 depth 단계까지 잘라 prefix 반환.</p>
<pre><code>예) key=&#39;logs/2024/01/app.log&#39;, depth=2  →  &#39;logs/2024/&#39;
    key=&#39;top-level.txt&#39;,       depth=1  →  &#39;(root)&#39;
&quot;&quot;&quot;
parts = key.rstrip(&quot;/&quot;).split(&quot;/&quot;)
dir_parts = parts[:-1]   # 파일명(마지막) 제외
if len(dir_parts) &lt; depth:
    return &quot;(root)&quot;
return &quot;/&quot;.join(dir_parts[:depth]) + &quot;/&quot;</code></pre><h1 id="──────────────────────────────────────────────────────────-8">──────────────────────────────────────────────────────────</h1>
<h1 id="분석">분석</h1>
<h1 id="──────────────────────────────────────────────────────────-9">──────────────────────────────────────────────────────────</h1>
<p>def analyze(df: pd.DataFrame, depth: int,
            bucket_filter: str = None,
            prefix_filter: str = None,
            sort_by: str = &quot;size&quot;,
            top_n: int = None) -&gt; pd.DataFrame:</p>
<pre><code>work = df.copy()

if bucket_filter:
    work = work[work[&quot;Bucket&quot;] == bucket_filter]
    if work.empty:
        print(f&quot;[WARN] 버킷 &#39;{bucket_filter}&#39; 데이터 없음&quot;)
        print(f&quot;[INFO] 버킷 목록: {sorted(df[&#39;Bucket&#39;].dropna().unique())}&quot;)
        return pd.DataFrame()

if prefix_filter:
    work = work[work[&quot;Key&quot;].str.startswith(prefix_filter, na=False)]
    if work.empty:
        print(f&quot;[WARN] prefix &#39;{prefix_filter}&#39; 에 해당하는 Key 없음&quot;)
        return pd.DataFrame()

work[&quot;_prefix&quot;] = work[&quot;Key&quot;].apply(lambda k: extract_prefix_at_depth(str(k), depth))

result = (
    work.groupby([&quot;Bucket&quot;, &quot;_prefix&quot;], as_index=False)
    .agg(object_count=(&quot;Key&quot;, &quot;count&quot;), total_bytes=(&quot;Size&quot;, &quot;sum&quot;))
    .rename(columns={&quot;_prefix&quot;: &quot;prefix&quot;})
)

result[&quot;avg_object_size&quot;] = (
    result[&quot;total_bytes&quot;] / result[&quot;object_count&quot;].replace(0, float(&quot;nan&quot;))
)
tot_obj   = result[&quot;object_count&quot;].sum()
tot_bytes = result[&quot;total_bytes&quot;].sum()
result[&quot;object_pct&quot;] = (result[&quot;object_count&quot;] / tot_obj   * 100).round(2)
result[&quot;size_pct&quot;]   = (result[&quot;total_bytes&quot;]   / tot_bytes * 100).round(2)

sort_col = {&quot;size&quot;: &quot;total_bytes&quot;, &quot;count&quot;: &quot;object_count&quot;, &quot;prefix&quot;: &quot;prefix&quot;}[sort_by]
result = result.sort_values(sort_col, ascending=(sort_col == &quot;prefix&quot;)).reset_index(drop=True)

if top_n:
    result = result.head(top_n)
return result</code></pre><h1 id="──────────────────────────────────────────────────────────-10">──────────────────────────────────────────────────────────</h1>
<h1 id="출력">출력</h1>
<h1 id="──────────────────────────────────────────────────────────-11">──────────────────────────────────────────────────────────</h1>
<p>def print_result(result: pd.DataFrame, depth: int, fmt: str) -&gt; None:
    if result.empty:
        print(&quot;[INFO] 출력할 결과가 없습니다.&quot;)
        return</p>
<pre><code>tot_obj   = result[&quot;object_count&quot;].sum()
tot_bytes = result[&quot;total_bytes&quot;].sum()

print(&quot;\n&quot; + &quot;=&quot; * 72)
print(f&quot;  AIStor iNVENTORY 분석  |  depth = {depth}&quot;)
print(&quot;=&quot; * 72)
print(f&quot;  prefix 수      : {len(result):,}개&quot;)
print(f&quot;  총 object 수   : {tot_obj:,}개&quot;)
print(f&quot;  총 데이터 크기 : {human_size(tot_bytes)}&quot;)
print(&quot;=&quot; * 72 + &quot;\n&quot;)

if fmt == &quot;json&quot;:
    print(result.to_json(orient=&quot;records&quot;, indent=2, force_ascii=False))
    return
if fmt == &quot;csv&quot;:
    print(result.to_csv(index=False))
    return

disp = result.assign(
    total_size = result[&quot;total_bytes&quot;].apply(human_size),
    avg_size   = result[&quot;avg_object_size&quot;].apply(
                     lambda x: human_size(x) if not pd.isna(x) else &quot;-&quot;),
    obj_pct    = result[&quot;object_pct&quot;].apply(lambda x: f&quot;{x:.1f}%&quot;),
    size_pct   = result[&quot;size_pct&quot;].apply(lambda x: f&quot;{x:.1f}%&quot;),
)[[
    &quot;Bucket&quot;, &quot;prefix&quot;, &quot;object_count&quot;, &quot;total_size&quot;, &quot;avg_size&quot;, &quot;obj_pct&quot;, &quot;size_pct&quot;,
]].rename(columns={
    &quot;Bucket&quot;: &quot;버킷&quot;, &quot;prefix&quot;: &quot;Prefix&quot;, &quot;object_count&quot;: &quot;객체 수&quot;,
    &quot;total_size&quot;: &quot;총 크기&quot;, &quot;avg_size&quot;: &quot;평균 크기&quot;,
    &quot;obj_pct&quot;: &quot;객체 비율&quot;, &quot;size_pct&quot;: &quot;크기 비율&quot;,
})

if HAS_TABULATE:
    print(tabulate(disp, headers=&quot;keys&quot;, tablefmt=&quot;rounded_outline&quot;,
                   showindex=True, numalign=&quot;right&quot;))
else:
    print(disp.to_string(index=True))
    print(&quot;\n[TIP] pip install tabulate  →  더 예쁜 테이블 출력&quot;)</code></pre><p>def print_info(df: pd.DataFrame) -&gt; None:
    print(&quot;\n&quot; + &quot;─&quot; * 54)
    print(&quot;  데이터셋 기본 정보&quot;)
    print(&quot;─&quot; * 54)
    print(f&quot;  총 object 수  : {len(df):,}&quot;)
    print(f&quot;  컬럼          : {list(df.columns)}&quot;)
    print(f&quot;  총 데이터 크기: {human_size(df[&#39;Size&#39;].sum())}&quot;)
    print(f&quot;  버킷 목록     : {sorted(df[&#39;Bucket&#39;].dropna().unique().tolist())}&quot;)
    print(&quot;\n  depth별 unique prefix 수:&quot;)
    for d in range(1, 7):
        n = df[&quot;Key&quot;].apply(lambda k: extract_prefix_at_depth(str(k), d)).nunique()
        print(f&quot;    depth={d}: {n:&gt;8,}개 prefix&quot;)
    print(&quot;─&quot; * 54)
    print(&quot;[TIP] --depth 값을 위 표를 참고해 선택하세요.\n&quot;)</p>
<p>def save_result(result: pd.DataFrame, path: str, fmt: str) -&gt; None:
    if fmt == &quot;json&quot;:
        result.to_json(path, orient=&quot;records&quot;, indent=2, force_ascii=False)
    else:
        result.to_csv(path, index=False, encoding=&quot;utf-8-sig&quot;)
    print(f&quot;[INFO] 저장 완료: {path}&quot;)</p>
<h1 id="──────────────────────────────────────────────────────────-12">──────────────────────────────────────────────────────────</h1>
<h1 id="cli">CLI</h1>
<h1 id="──────────────────────────────────────────────────────────-13">──────────────────────────────────────────────────────────</h1>
<p>def parse_args():
    p = argparse.ArgumentParser(
        description=&quot;AIStor iNVENTORY parquet를 원격에서 직접 읽어 depth별 prefix 집계&quot;,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=&quot;&quot;&quot;
환경변수 (--access-key/--secret-key 대신 사용 가능):
  AISTOR_ENDPOINT    MinIO endpoint  (예: minio.example.com:9000)
  AISTOR_ACCESS_KEY  액세스 키
  AISTOR_SECRET_KEY  시크릿 키</p>
<p>예시:</p>
<h1 id="job-목록과-timestamp-확인">job 목록과 timestamp 확인</h1>
<p>  python aistor_inventory_analyzer.py \
      --endpoint minio.example.com:9000 \
      --access-key AKID --secret-key SKID \
      --dest-bucket inventory-reports --source-bucket devops-test \
      --list-jobs</p>
<h1 id="최신-timestamp-자동-선택-depth2-분석">최신 timestamp 자동 선택, depth=2 분석</h1>
<p>  python aistor_inventory_analyzer.py \
      --endpoint minio.example.com:9000 \
      --access-key AKID --secret-key SKID \
      --dest-bucket inventory-reports --source-bucket devops-test \
      --inventory-id my-job-id --depth 2</p>
<h1 id="특정-timestamp-지정-top-30-csv-저장">특정 timestamp 지정, Top 30, CSV 저장</h1>
<p>  python aistor_inventory_analyzer.py \
      --endpoint minio.example.com:9000 \
      --access-key AKID --secret-key SKID \
      --dest-bucket inventory-reports --source-bucket devops-test \
      --inventory-id my-job-id \
      --timestamp 2025-01-15T03-00Z \
      --depth 3 --sort-by size --top 30 --save result.csv</p>
<h1 id="prefix-드릴다운--json-출력">prefix 드릴다운 + JSON 출력</h1>
<p>  python aistor_inventory_analyzer.py ... \
      --inventory-id my-job-id --depth 4 \
      --prefix-filter logs/2024/ -o json
        &quot;&quot;&quot;,
    )</p>
<pre><code># 연결 정보
conn = p.add_argument_group(&quot;MinIO 연결 정보&quot;)
conn.add_argument(&quot;--endpoint&quot;,   &quot;-e&quot;,
                  default=os.environ.get(&quot;AISTOR_ENDPOINT&quot;),
                  help=&quot;MinIO endpoint (예: minio.example.com:9000)&quot;)
conn.add_argument(&quot;--access-key&quot;,
                  default=os.environ.get(&quot;AISTOR_ACCESS_KEY&quot;),
                  help=&quot;액세스 키 (환경변수 AISTOR_ACCESS_KEY 도 가능)&quot;)
conn.add_argument(&quot;--secret-key&quot;,
                  default=os.environ.get(&quot;AISTOR_SECRET_KEY&quot;),
                  help=&quot;시크릿 키 (환경변수 AISTOR_SECRET_KEY 도 가능)&quot;)
conn.add_argument(&quot;--no-tls&quot;,     action=&quot;store_true&quot;,
                  help=&quot;TLS 비활성화 (http 접속)&quot;)

# 경로 정보
path = p.add_argument_group(&quot;inventory 경로&quot;)
path.add_argument(&quot;--dest-bucket&quot;,   required=True,
                  help=&quot;inventory 결과가 저장된 버킷명&quot;)
path.add_argument(&quot;--prefix&quot;,        default=&quot;&quot;,
                  help=&quot;dest-bucket 안의 prefix (설정 YAML의 destination.prefix, 없으면 생략)&quot;)
path.add_argument(&quot;--source-bucket&quot;, required=True,
                  help=&quot;인벤토리를 생성한 원본 버킷명&quot;)
path.add_argument(&quot;--inventory-id&quot;,
                  help=&quot;inventory job ID (--list-jobs 생략 시 필수)&quot;)
path.add_argument(&quot;--timestamp&quot;,     default=None,
                  help=&quot;특정 실행 timestamp (예: 2025-01-15T03-00Z). 생략 시 최신 자동 선택&quot;)

# 분석 옵션
ana = p.add_argument_group(&quot;분석 옵션&quot;)
ana.add_argument(&quot;--depth&quot;,         &quot;-d&quot;, type=int,
                 help=&quot;분석할 prefix depth (1~)&quot;)
ana.add_argument(&quot;--bucket-filter&quot;, &quot;-b&quot;, default=None,
                 help=&quot;특정 Bucket 컬럼 값으로 필터&quot;)
ana.add_argument(&quot;--prefix-filter&quot;,       default=None,
                 help=&quot;Key prefix 필터 (예: logs/2024/)&quot;)
ana.add_argument(&quot;--sort-by&quot;,
                 choices=[&quot;size&quot;, &quot;count&quot;, &quot;prefix&quot;], default=&quot;size&quot;)
ana.add_argument(&quot;--top&quot;,           type=int, default=None,
                 help=&quot;상위 N개만 출력&quot;)
ana.add_argument(&quot;--output&quot;, &quot;-o&quot;,
                 choices=[&quot;table&quot;, &quot;csv&quot;, &quot;json&quot;], default=&quot;table&quot;)
ana.add_argument(&quot;--save&quot;,   &quot;-s&quot;,  default=None,
                 help=&quot;결과 저장 경로&quot;)

# 탐색 모드
p.add_argument(&quot;--list-jobs&quot;,  action=&quot;store_true&quot;,
               help=&quot;job ID / timestamp 목록만 출력하고 종료&quot;)
p.add_argument(&quot;--info-only&quot;,  action=&quot;store_true&quot;,
               help=&quot;데이터 로드 후 스키마/depth 분포만 출력&quot;)

return p.parse_args()</code></pre><p>def main():
    args = parse_args()</p>
<pre><code># 연결 정보 검증
if not args.endpoint:
    sys.exit(&quot;[ERROR] --endpoint 또는 환경변수 AISTOR_ENDPOINT 가 필요합니다.&quot;)
if not args.access_key or not args.secret_key:
    sys.exit(&quot;[ERROR] --access-key / --secret-key 또는 환경변수가 필요합니다.&quot;)

client = make_client(
    args.endpoint,
    args.access_key,
    args.secret_key,
    secure=not args.no_tls,
)

# ── --list-jobs ──────────────────────────────────────
if args.list_jobs:
    print(f&quot;\n[INFO] &#39;{args.dest_bucket}&#39; 버킷의 inventory job 목록 조회 중...&quot;)
    jobs = list_jobs(client, args.dest_bucket, args.prefix, args.source_bucket)
    if not jobs:
        print(&quot;[INFO] inventory job을 찾을 수 없습니다.&quot;)
        return
    print(f&quot;\n{&#39;Job ID&#39;:&lt;40}  {&#39;Timestamp (최신순)&#39;}&quot;)
    print(&quot;─&quot; * 72)
    for jid, timestamps in sorted(jobs.items()):
        for i, ts in enumerate(timestamps):
            label = jid if i == 0 else &quot;&quot;
            latest = &quot; ← 최신&quot; if i == 0 else &quot;&quot;
            print(f&quot;{label:&lt;40}  {ts}{latest}&quot;)
    print()
    return

# ── inventory-id 필수 확인 ────────────────────────────
if not args.inventory_id:
    sys.exit(&quot;[ERROR] --inventory-id 가 필요합니다. --list-jobs 로 목록을 먼저 확인하세요.&quot;)

# ── timestamp 자동 선택 ───────────────────────────────
timestamp = args.timestamp
if not timestamp:
    print(&quot;[INFO] --timestamp 미지정 → 최신 timestamp 자동 선택 중...&quot;)
    jobs = list_jobs(client, args.dest_bucket, args.prefix, args.source_bucket)
    ts_list = jobs.get(args.inventory_id, [])
    if not ts_list:
        sys.exit(f&quot;[ERROR] inventory-id &#39;{args.inventory_id}&#39; 를 찾을 수 없습니다.&quot;)
    timestamp = ts_list[0]
    print(f&quot;[INFO] 선택된 timestamp: {timestamp}&quot;)

# ── manifest 읽기 ─────────────────────────────────────
manifest_key = resolve_manifest_key(
    args.prefix, args.source_bucket, args.inventory_id, timestamp
)
print(f&quot;[INFO] manifest 읽기: {args.dest_bucket}/{manifest_key}&quot;)
manifest = read_manifest(client, args.dest_bucket, manifest_key)

fmt = manifest.get(&quot;fileFormat&quot;, &quot;parquet&quot;).lower()
if fmt != &quot;parquet&quot;:
    sys.exit(f&quot;[ERROR] 이 도구는 parquet 포맷만 지원합니다. (현재 포맷: {fmt})&quot;)

print(f&quot;[INFO] sourceBucket : {manifest.get(&#39;sourceBucket&#39;, &#39;?&#39;)}&quot;)
print(f&quot;[INFO] inventoryId  : {manifest.get(&#39;inventoryId&#39;, &#39;?&#39;)}&quot;)
print(f&quot;[INFO] createdAt    : {manifest.get(&#39;createdAt&#39;, &#39;?&#39;)}&quot;)
file_count = len(manifest.get(&quot;files&quot;, []))
print(f&quot;[INFO] parquet 파일 : {file_count}개\n&quot;)

# ── parquet 키 목록 ───────────────────────────────────
parquet_keys = resolve_parquet_keys(
    manifest, args.dest_bucket,
    args.prefix, args.source_bucket,
    args.inventory_id, timestamp,
)

# ── 스트리밍 로드 ─────────────────────────────────────
df = load_all_parquets(client, args.dest_bucket, parquet_keys)

# 타입 정규화
df[&quot;Size&quot;] = pd.to_numeric(df[&quot;Size&quot;], errors=&quot;coerce&quot;).fillna(0)

# ── --info-only ───────────────────────────────────────
if args.info_only:
    print_info(df)
    return

if not args.depth:
    sys.exit(&quot;[ERROR] --depth 가 필요합니다. --info-only 로 depth 분포를 먼저 확인하세요.&quot;)

# ── 분석 &amp; 출력 ───────────────────────────────────────
result = analyze(
    df,
    depth=args.depth,
    bucket_filter=args.bucket_filter,
    prefix_filter=args.prefix_filter,
    sort_by=args.sort_by,
    top_n=args.top,
)

print_result(result, args.depth, args.output)

if args.save and not result.empty:
    save_result(result, args.save, args.output)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26A10c]]></title>
            <link>https://velog.io/@youngkyoo_kim/26A10c</link>
            <guid>https://velog.io/@youngkyoo_kim/26A10c</guid>
            <pubDate>Fri, 10 Apr 2026 02:21:40 GMT</pubDate>
            <description><![CDATA[<p>두 개의 Parquet 파일을 합치는 방법은 크게 <strong>데이터를 메모리에 올려서 합치는 방식(Pandas/Polars)</strong>과 <strong>파일 시스템 수준에서 단순히 경로를 통합하는 방식</strong>이 있습니다. </p>
<p>교수님의 상황(MinIO에서 가져온 인벤토리 파일 처리)에서는 <strong>Pandas</strong>를 사용하는 것이 가장 유연하고 안정적입니다.</p>
<hr>
<h3 id="1-pandas를-사용하여-합치기-추천">1. Pandas를 사용하여 합치기 (추천)</h3>
<p>두 파일을 데이터프레임으로 읽어와서 <code>pd.concat</code> 함수를 사용합니다. 컬럼 구조가 동일할 때 가장 확실한 방법입니다.</p>
<pre><code class="language-python">import pandas as pd

# 두 개의 Parquet 파일 읽기
df1 = pd.read_parquet(&#39;file1.parquet&#39;)
df2 = pd.read_parquet(&#39;file2.parquet&#39;)

# 세로로 합치기 (Row append)
combined_df = pd.concat([df1, df2], ignore_index=True)

# 합쳐진 결과를 새로운 파일로 저장
combined_df.to_parquet(&#39;combined_result.parquet&#39;, engine=&#39;pyarrow&#39;)</code></pre>
<h3 id="2-여러-개의-파일을-한-번에-읽기-glob-패턴">2. 여러 개의 파일을 한 번에 읽기 (Glob 패턴)</h3>
<p>만약 <code>devops/</code> 버킷 안에 여러 날짜의 Parquet 파일이 흩어져 있다면, 하나씩 읽을 필요 없이 리스트를 전달하여 한 번에 합칠 수 있습니다.</p>
<pre><code class="language-python">import pandas as pd
import glob

# 특정 패턴의 모든 파일 경로 가져오기
files = glob.glob(&#39;inventory_*.parquet&#39;)

# 리스트 컴프리헨션으로 한 번에 합치기
combined_df = pd.concat([pd.read_parquet(f) for f in files], ignore_index=True)</code></pre>
<h3 id="3-pyarrow를-사용하여-합치기-대용량인-경우">3. PyArrow를 사용하여 합치기 (대용량인 경우)</h3>
<p>데이터가 수천만 건 이상으로 매우 커서 Pandas가 무겁게 느껴진다면, 더 하위 레벨 라이브러리인 <code>pyarrow</code>를 직접 사용하는 것이 메모리 효율적입니다.</p>
<pre><code class="language-python">import pyarrow.parquet as pq
import pyarrow as pa

# 테이블 형태로 읽기
table1 = pq.read_table(&#39;file1.parquet&#39;)
table2 = pq.read_table(&#39;file2.parquet&#39;)

# 테이블 합치기 (메모리 복사를 최소화함)
combined_table = pa.concat_tables([table1, table2])

# 저장
pq.write_table(combined_table, &#39;combined_result.parquet&#39;)</code></pre>
<hr>
<h3 id="💡-minio-환경에서의-팁">💡 MinIO 환경에서의 팁</h3>
<p>앞서 만드신 프로그램에 적용하신다면, <code>client.get_object</code>로 가져온 두 개의 스트림을 각각 데이터프레임으로 변환한 뒤 <code>pd.concat</code> 하시면 됩니다.</p>
<p><strong>주의할 점:</strong></p>
<ul>
<li><strong>컬럼 불일치</strong>: 두 파일의 컬럼명이 다르면 <code>NaN</code> 값이 생성될 수 있으니 합치기 전 <code>df.columns</code>로 구조를 확인하는 것이 좋습니다.</li>
<li><strong>인덱스 중복</strong>: <code>ignore_index=True</code> 옵션을 주어야 합친 후에 인덱스가 <code>0, 1, 2... 0, 1, 2</code>처럼 꼬이지 않고 깔끔하게 정리됩니다.</li>
</ul>
<p>파일을 합치는 과정에서 특정 날짜별로 데이터를 구분해야 한다면, 합치기 전에 <code>df[&#39;source_date&#39;] = &#39;2026-04-10&#39;</code> 같은 식으로 컬럼을 하나 추가해 두는 것도 좋은 관리 방법입니다.</p>
]]></description>
        </item>
    </channel>
</rss>