<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://joyuno.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://joyuno.github.io/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-10-28T22:02:05+09:00</updated><id>https://joyuno.github.io/feed.xml</id><title type="html">Joyuno’s Dev Blog</title><subtitle>주니어 AI 엔지니어 joyuno 의 개발 회고 블로그. Claude / OpenAI / DeepSeek 등 AI 도구 활용 노하우, 프롬프팅 패턴, AI 활용 개발일지(dev-blog) 자동 생성 기록을 매일 공유합니다.</subtitle><author><name>joyuno</name><email>joy981017@gmail.com</email></author><entry><title type="html">2026-10-28 (수) 개발일지</title><link href="https://joyuno.github.io/blog/2026/10/28/dev-blog/" rel="alternate" type="text/html" title="2026-10-28 (수) 개발일지" /><published>2026-10-28T00:00:00+09:00</published><updated>2026-10-28T00:00:00+09:00</updated><id>https://joyuno.github.io/blog/2026/10/28/dev-blog</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/28/dev-blog/"><![CDATA[<p>오늘은 세 가지가 걸렸다. 비용 집계가 틀렸던 것, 자동완성 패널이 검게 보이던 것, 그리고 발행된 글에 원본에 없는 문장이 섞여 있던 것. 앞의 둘은 원인이 한 줄로 떨어졌고, 마지막은 수집 단계까지 거슬러 올라가야 했다.</p>

<h2 id="하네스--비용이-매-턴-늘기만-한-건-누적값을-다시-더하고-있어서였다">하네스 — 비용이 매 턴 늘기만 한 건 누적값을 다시 더하고 있어서였다</h2>

<p>지난번에 실행 비용이 예상의 5배를 넘었다고 정리했는데, 그 숫자부터가 틀렸다. 14개 대화를 다시 훑어보니 비용이 매 턴 늘기만 하고 줄어드는 구간이 하나도 없었다. 정상이라면 턴마다 그 턴의 사용량만큼만 붙어야 하는데, 그렇지 않다는 뜻이었다.</p>

<blockquote>
  <p>세션을 이어 쓸 때 보고되는 비용은 그 세션의 누적값이었다. 나는 그 누적값을 턴마다 다시 더하고 있었다.</p>
</blockquote>

<p>화면에 찍힌 값과 실제를 맞춰 보니 차이가 컸다.</p>

<table>
  <thead>
    <tr>
      <th>실행</th>
      <th>화면에 찍힌 값</th>
      <th>실제 비용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>10-23 (각 1회)</td>
      <td>$23.4</td>
      <td>약 $5.3</td>
    </tr>
    <tr>
      <td>이번 (각 3회)</td>
      <td>$82.6</td>
      <td>약 $15.3 (riff $8.8 · 단독 $6.5)</td>
    </tr>
  </tbody>
</table>

<p>이번 실행은 미리 잡아 둔 예상 $30~45보다도 적게 나왔다. 계산 방식을 고쳐 커밋했다.</p>

<h2 id="하네스--eval이-통과하지-못한-규칙은-문구가-아니라-도구-호출-형태-문제였다">하네스 — eval이 통과하지 못한 규칙은 문구가 아니라 도구 호출 형태 문제였다</h2>

<p>riff가 사용자에게 내부 용어를 흘리는 문제가 있었다. 3회 모두 “CANVAS”가 나왔고, 한 번은 “복잡 depth”, “FRAME”, “A-007”까지 그대로 갔다. 단독 실행에서는 0회였다. 비개발자에게는 그대로 혼란이다.</p>

<p>먼저 <code class="language-plaintext highlighter-rouge">plain-language</code> eval을 돌렸다. 켠 쪽 4회 중 2회가 <code class="language-plaintext highlighter-rouge">no-jargon: pattern found</code>로 실패했다. 수정 전 실패(red)가 확인된 셈이라 규칙을 넣고 다시 돌려 4/4로 통과시켰다.</p>

<p>여기서 끝이 아니었다. 업종 팩을 읽히는 규칙은 서술형 문구로는 0/3이었다. “<code class="language-plaintext highlighter-rouge">~/.riff/domains</code>를 찾는다”라고 적어 두면 규칙을 읽고도 실제로 찾지 않았다. <code class="language-plaintext highlighter-rouge">Glob(pattern: "*.md", path: "~/.riff/domains")</code>처럼 <strong>도구 호출 형태</strong>로 적자 3/3이 됐다.</p>

<blockquote>
  <p>규칙은 “무엇을 한다”가 아니라 “어떤 도구를 어떤 인자로 부른다”로 적어야 먹혔다.</p>
</blockquote>

<p>덤으로 알게 된 것도 있다. eval 환경은 플러그인 참고 문서(<code class="language-plaintext highlighter-rouge">references/</code>) 읽기를 막는다. 그래서 eval로 검증되는 규칙은 사실상 <code class="language-plaintext highlighter-rouge">SKILL.md</code>에 적힌 것뿐이고, 참고 문서 쪽 규칙은 실제 사용에서만 돈다.</p>

<h2 id="게임--자동완성-패널이-검게-보인-건-테마가-팝업을-아예-안-칠해서였다">게임 — 자동완성 패널이 검게 보인 건 테마가 팝업을 아예 안 칠해서였다</h2>

<p>코드 자동완성 패널이 검은 막대에 어두운 글자로 깨져 보인다는 제보가 있었다. 색을 바꾸기 전에 Godot가 실제로 노출하는 항목 이름과 기본값부터 확인했다. 추측으로 테마를 만지면 어디를 고쳤는지도 모르게 된다.</p>

<p>숫자가 바로 나왔다. <code class="language-plaintext highlighter-rouge">completion_background_color</code> 기본값이 <code class="language-plaintext highlighter-rouge">(0.17, 0.16, 0.2)</code>로 #2B2933, 거의 검정이었다. 옵션 글자색은 <code class="language-plaintext highlighter-rouge">C_TEXT</code> = <code class="language-plaintext highlighter-rouge">#3C3C3C</code>이고 <code class="language-plaintext highlighter-rouge">python_completion_controller.gd:88</code>에서 명시적으로 넘기고 있었다. 명암비 약 <strong>1.2:1</strong>이니 안 읽히는 게 당연했다.</p>

<p>원인은 색값이 아니라 적용 경로였다. 팝업은 <code class="language-plaintext highlighter-rouge">PopupPanel</code>이 아니라 <code class="language-plaintext highlighter-rouge">CodeEdit</code>이 직접 그리는 개체라서, <code class="language-plaintext highlighter-rouge">_apply_editor()</code>가 에디터 색을 다 깔아도 팝업만 Godot 기본값에 남아 있었다. <code class="language-plaintext highlighter-rouge">theme_setup.gd:227</code>에 팝업 5색과 <code class="language-plaintext highlighter-rouge">completion</code> 스타일박스를 추가했다.</p>

<p>재발 방지가 더 까다로웠다. 명암비만 검사하면 “테마가 그 색을 <strong>아예 안 깔았다</strong>“는 원래 결함을 못 잡는다. 그래서 <code class="language-plaintext highlighter-rouge">theme_contract_test.gd</code>에 <strong>Godot 기본값과 같으면 실패</strong>하는 검사 11개를 넣었다. 179 checks 0 failed, 전체 20/20 통과.</p>

<p>검증 중에 캡처 스크립트가 <code class="language-plaintext highlighter-rouge">Identifier not found: ThemeSetup</code>으로 죽었다. <code class="language-plaintext highlighter-rouge">--script</code> 메인 루프는 autoload 등록 전에 컴파일되기 때문이었다. 런타임 접근으로 바꾸자 캡처가 돌았고, 수정 전 화면(검은 막대)과 수정 후 화면(흰 카드 + 테두리, 입력 접두사에 연두 띠)이 나란히 찍혔다.</p>

<h2 id="옵시디언-정리--발행된-글에-로그에-없는-문장이-섞여-있었다">옵시디언 정리 — 발행된 글에 로그에 없는 문장이 섞여 있었다</h2>

<p>발행된 글을 원본 기록과 대조하니 없던 장면이 있었다. “앱을 띄워 3분할 드래그를 반복해봤다”고 썼는데 원본에는 “이번 빌드는 실제 앱으로 띄워 보지 않았습니다”라고 적혀 있었다. 원인 추적 서사 전체가 지어낸 것이었다.</p>

<p>왜 그런지 입력 쪽을 재 봤다. 최근 7일 동안 글 생성에 넘기는 발췌 줄 가운데 오류·원인을 언급하는 줄이 <strong>3~14%</strong>뿐이었다. 이유는 네 가지였다. 도구 실행 결과를 아예 수집하지 않았고, assistant 문장을 300자에서 자르고 다시 250자에서 잘랐고, 세션당 6줄을 내용과 무관하게 처음·중간·끝 위치로 골랐고, 마지막 50개 메시지만 봤다. 게다가 세션 58개 중 48개가 임시 폴더에서 돌아간 평가 시뮬레이션이었다.</p>

<blockquote>
  <p>입력에 원인이 없으니 생성 단계는 “전환점이 1~2개 있어야 한다”는 요구를 지어내서 채웠다. 날조 금지 규칙보다 형식 요구가 세게 작동한 것이다.</p>
</blockquote>

<p>수집 단계에서 서브에이전트 기록과 임시 폴더 세션을 빼고, 자르기를 없애고, 실패한 도구 결과를 <code class="language-plaintext highlighter-rouge">[오류] 명령 → 메시지</code> 형태로 함께 넣었다. 프롬프트에서는 전환점과 가설을 로그에 있을 때만 쓰게 바꿨다.</p>

<p>발행 전 검사는 두 단계로 나눴다. 먼저 글 전체를 버리는 검사(프롬프트 문구 유출, 같은 제목 반복, 작성 메모 노출)를 10월 발행 글 10편에 돌렸다. 문제 있던 5편이 62~81%로 걸리고 정상 5편은 0~31%로 갈렸다. 처음 기준 30%에서는 10-19가 잘못 걸려서 50%로 올렸다.</p>

<p>그다음 문장 단위로 바꿨다. 문장마다 숫자와 <code class="language-plaintext highlighter-rouge">코드</code>를 로그와 대조해 없는 것만 지운다. 지어낸 글 5편은 21~48%가 지워져 전체 재생성으로 넘어갔고, 정상 글은 0~10%만 지워져 나머지를 그대로 발행했다.</p>

<p>남은 구멍은 분명하다. 숫자나 코드가 없는 모순은 검사로 안 잡힌다. 새로 만든 글도 도입에서는 “메시지가 진짜 원인을 가리켰다”고 하고 마지막 한줄에서는 “메시지가 원인과 달랐다”고 썼는데, 문장 단위 검사로는 둘 다 통과한다.</p>

<h3 id="오늘의-한줄">오늘의 한줄</h3>

<p>집계도, 테마도, 글도 결국 <strong>“무엇을 근거로 삼았는가”</strong> 에서 갈렸다 — 누적값을 턴 값으로 착각하고, 기본값을 적용된 값으로 착각하고, 형식 요구를 사실로 착각한 하루였다.</p>]]></content><author><name>joyuno</name></author><category term="dev-blog" /><category term="dev-blog" /><category term="til" /><summary type="html"><![CDATA[오늘은 세 가지가 걸렸다. 비용 집계가 틀렸던 것, 자동완성 패널이 검게 보이던 것, 그리고 발행된 글에 원본에 없는 문장이 섞여 있던 것. 앞의 둘은 원인이 한 줄로 떨어졌고, 마지막은 수집 단계까지 거슬러 올라가야 했다.]]></summary></entry><entry><title type="html">NGINX 1.31.8 업그레이드: 패키지 기준으로 버전 진실 맞추기</title><link href="https://joyuno.github.io/blog/2026/10/27/nginx-mainline-package-version-source-of-truth/" rel="alternate" type="text/html" title="NGINX 1.31.8 업그레이드: 패키지 기준으로 버전 진실 맞추기" /><published>2026-10-27T13:27:38+09:00</published><updated>2026-10-27T13:27:38+09:00</updated><id>https://joyuno.github.io/blog/2026/10/27/nginx-mainline-package-version-source-of-truth</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/27/nginx-mainline-package-version-source-of-truth/"><![CDATA[<p>nginx를 운영에서 다루다 보면 버전이 한 가지가 아니라는 사실부터가 작업 난이도를 올립니다. <code class="language-plaintext highlighter-rouge">nginx -v</code>가 찍는 숫자, Debian/Ubuntu에서 <code class="language-plaintext highlighter-rouge">apt</code>가 보는 버전 문자열, nginx.org 패키지 풀의 <code class="language-plaintext highlighter-rouge">orig.tar.gz</code> 타임스탬프, 그리고 공식 문서에 적힌 “latest”가 서로 어긋나는 순간이 반드시 옵니다.</p>

<p>2026-10-21에 nginx.org news에 <code class="language-plaintext highlighter-rouge">nginx-1.31.8</code> mainline 릴리스가 공지됐고, 같은 날 nginx.org의 Debian mainline 패키지 풀에 <code class="language-plaintext highlighter-rouge">nginx_1.31.8.orig.tar.gz</code>와 <code class="language-plaintext highlighter-rouge">nginx_1.31.8-1~bookworm_*</code>가 올라온 게 인덱스에서 보입니다.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> 이 타이밍에 “우리 조직에서 nginx 버전의 기준선이 무엇인가”를 문서화해 두면, 다음 보안 공지나 긴급 패치가 왔을 때 커뮤니케이션 비용이 급격히 줄어듭니다.</p>

<p>아래는 내가 운영팀 관점에서 정리해 둔 기준선과 검증 플로우입니다. 결론은 단순합니다. 배포는 패키지로 하고, 버전의 진실은 패키지 메타데이터와 실행 바이너리를 기준으로 맞추고, 문서는 참고 자료로만 둡니다.</p>

<h2 id="1318을-둘러싼-세-가지-타임라인">1.31.8을 둘러싼 세 가지 타임라인</h2>

<p>이번 케이스를 “버전이 갈라지는 지점” 관찰용으로 보면, 근거를 확보하기가 쉽습니다.</p>

<ul>
  <li>릴리스 공지: nginx.org news에 2026-10-21자로 <code class="language-plaintext highlighter-rouge">nginx-1.31.8 mainline version has been released.</code>가 올라와 있습니다.<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
  <li>공식 다운로드 엔트리: nginx.org download 페이지는 mainline 버전으로 <code class="language-plaintext highlighter-rouge">nginx-1.31.8</code>과 <code class="language-plaintext highlighter-rouge">nginx/Windows-1.31.8</code>을 같은 줄에 둡니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>Debian mainline 패키지 풀: <code class="language-plaintext highlighter-rouge">/packages/mainline/debian/pool/nginx/n/nginx/</code> 인덱스에 <code class="language-plaintext highlighter-rouge">nginx_1.31.8.orig.tar.gz</code>, <code class="language-plaintext highlighter-rouge">nginx_1.31.8-1~bookworm.dsc</code>, <code class="language-plaintext highlighter-rouge">nginx_1.31.8-1~bookworm_amd64.deb</code>가 2026-10-21 18:26로 표시됩니다(인덱스 표기 기준).<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
</ul>

<p>여기서 이미 운영상 중요한 사실 하나가 나옵니다.</p>

<ul>
  <li>upstream 버전(1.31.8)과 Debian 패키지 버전(<code class="language-plaintext highlighter-rouge">1.31.8-1~bookworm</code>)은 같은 릴리스를 가리키지만 문자열이 다릅니다.</li>
  <li>Windows 배포판은 파일/디렉터리 이름에 upstream 버전만 넣습니다.</li>
  <li>문서(Windows 문서 포함)는 “latest mainline”을 권장하지만, 문서는 버전 문자열을 정규화해 주지 않습니다.</li>
</ul>

<p>즉, 같은 1.31.8이라도 “어느 채널에서 무엇을 설치했는지”를 함께 말하지 않으면 운영 커뮤니케이션이 깨집니다.</p>

<h2 id="nginx에서-말하는-stablemainline과-운영에서의-의미">nginx에서 말하는 stable/mainline과 운영에서의 의미</h2>

<p>mainline/stable은 단순히 “실험/안정”의 감정적 구분이 아니라, 배포 스트림과 백포트 정책의 차이로 이해해야 합니다.</p>

<p>공식 nginx GitHub 저장소는 stable과 mainline을 이렇게 구분합니다. stable은 stable 브랜치에서 빌드되고 mainline에서 critical fix를 백포트해 담고, mainline은 master에서 빌드되며 최신 기능과 버그픽스를 포함한다고 설명합니다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<p>Windows 문서는 더 노골적으로 운영 기준을 흔듭니다. nginx/Windows 설치 설명에서 “latest mainline version distribution (1.31.8)”을 받으라고 쓰고, 그 이유를 “mainline branch contains all known fixes”로 적습니다.<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></p>

<p>내 운영 결론은 이렇습니다.</p>

<ul>
  <li>보안/취약점 대응 속도를 KPI로 잡는 조직이면 mainline이 기본값이 됩니다. upstream이 알려주는 “known fixes”가 mainline에 먼저/빠르게 모이는 구조를 인정해야 합니다.<sup id="fnref:5:1" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li>반대로, 외부 모듈 의존성이 크거나(예: 서드파티 dynamic module), 패키지 채널을 단일화하기 어려운 조직이면 stable이 아니라 “배포판 제공 패키지”가 기본값이 되는 경우가 많습니다. Debian bookworm은 nginx가 1.22.1 계열로 유지되고 있음을 sources.debian.org에서 바로 확인할 수 있습니다.<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
</ul>

<p>mainline이든 stable이든 중요한 건 일관성입니다. 버전을 맞추는 작업에서 가장 비용이 큰 건 “버전 숫자”가 아니라 “출처가 섞인 상태”입니다.</p>

<h2 id="버전-진실을-쪼개서-정의하기-upstream--package--running">버전 진실을 쪼개서 정의하기: upstream / package / running</h2>

<p>운영에서 버전 진실을 한 문장으로 정의하려고 하면 항상 실패합니다. 대신 세 가지로 나눠서 기록하는 게 안전합니다.</p>

<p>1) upstream version</p>
<ul>
  <li>nginx가 릴리스한 소스 버전입니다. 1.31.8 같은 숫자가 여기에 해당합니다.</li>
  <li>릴리스 공지(news), CHANGES, tarball 이름이 이 버전을 중심으로 움직입니다.<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>2) package version</p>
<ul>
  <li>패키징이 붙는 순간 upstream 버전 뒤에 distro/packaging revision이 붙습니다.</li>
  <li>nginx.org Debian mainline은 bookworm에서 <code class="language-plaintext highlighter-rouge">1.31.8-1~bookworm</code> 형태를 씁니다.<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
</ul>

<p>3) running binary identity</p>
<ul>
  <li>실제로 실행 중인 바이너리가 자신을 뭐라고 보고하는지, 어떤 옵션으로 빌드됐는지, 어떤 모듈이 로드되는지가 최종 진실입니다.</li>
  <li>nginx는 <code class="language-plaintext highlighter-rouge">-v</code>로 버전, <code class="language-plaintext highlighter-rouge">-V</code>로 빌드 파라미터까지 출력합니다.<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></li>
  <li>설정 검증은 <code class="language-plaintext highlighter-rouge">-t</code>, 전체 설정 덤프는 <code class="language-plaintext highlighter-rouge">-T</code>로 합니다.<sup id="fnref:7:1" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></li>
</ul>

<p>이렇게 쪼개면 “문서에서는 1.31.8이라는데 우리 서버는 1.31.8-1~bookworm이 찍힌다” 같은 대화가 헛바퀴를 덜 탑니다. upstream version은 같고 package version이 다르다고 말하면 됩니다.</p>

<h2 id="패키지-버전-문자열이-운영에-중요한-이유-bookworm의-의미">패키지 버전 문자열이 운영에 중요한 이유: <code class="language-plaintext highlighter-rouge">~bookworm</code>의 의미</h2>

<p>nginx.org 패키지에서 자주 보게 되는 <code class="language-plaintext highlighter-rouge">1.31.8-1~bookworm</code> 같은 표기는 단순 장식이 아닙니다. 업그레이드/다운그레이드 가능성과 직결됩니다.</p>

<p>Debian Policy는 패키지 버전 포맷을 <code class="language-plaintext highlighter-rouge">[epoch:]upstream_version[-debian_revision]</code>로 정의합니다.<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup> 그리고 버전 비교 규칙에서 <code class="language-plaintext highlighter-rouge">~</code>가 “끝 문자열보다도 앞서는(가장 작은)” 정렬을 갖는다고 명시합니다.<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup> Ubuntu 문서도 같은 맥락에서 <code class="language-plaintext highlighter-rouge">~</code>가 모든 것보다 먼저 정렬된다고 설명합니다.<sup id="fnref:10" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">10</a></sup></p>

<p>운영 관점에서 이게 중요한 이유는 두 가지입니다.</p>

<ul>
  <li>같은 upstream(1.31.8)이라도 <code class="language-plaintext highlighter-rouge">-1~bookworm</code>과 <code class="language-plaintext highlighter-rouge">-1</code>은 버전 정렬 결과가 달라질 수 있습니다.</li>
  <li>repo가 여러 개 섞였을 때, “내가 의도한 패키지”가 우선순위에서 밀리면 <code class="language-plaintext highlighter-rouge">apt upgrade</code>가 예상치 못한 방향으로 움직일 수 있습니다.</li>
</ul>

<p>그래서 nginx.org가 공식 설치 문서에서 apt pinning을 강하게 권장하는 흐름이 자연스럽습니다. Debian 설치 예시는 <code class="language-plaintext highlighter-rouge">Pin: origin nginx.org</code>, <code class="language-plaintext highlighter-rouge">Pin-Priority: 900</code>로 nginx.org 패키지를 우선시키는 설정을 넣습니다.<sup id="fnref:11" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></p>

<p>이 지점부터가 버전 진실 맞추기의 핵심입니다. “버전 숫자를 1.31.8로 맞춘다”가 아니라 “버전 출처를 nginx.org mainline로 고정한다”가 기준선이 됩니다.</p>

<h2 id="기준선을-어디에-둘-것인가-레포--origtargz--문서">기준선을 어디에 둘 것인가: 레포 / orig.tar.gz / 문서</h2>

<p>운영팀이 합의해야 하는 건 “무엇이 1차 진실인가”입니다. 내 경험상 아래 순서가 가장 사고가 적었습니다.</p>

<p>1) running binary(현장 증거)</p>
<ul>
  <li>장애/취약점 대응에서 “지금 무엇이 떠 있냐”가 0순위입니다.</li>
  <li><code class="language-plaintext highlighter-rouge">nginx -v</code>, <code class="language-plaintext highlighter-rouge">nginx -V</code>, systemd unit 상태, 실제 로드된 모듈 목록이 여기에 포함됩니다.<sup id="fnref:7:2" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></li>
</ul>

<p>2) package manager(배포 증거)</p>
<ul>
  <li>Debian/Ubuntu면 <code class="language-plaintext highlighter-rouge">dpkg -s nginx</code>의 Version, <code class="language-plaintext highlighter-rouge">apt-cache policy nginx</code>의 Candidate, 그리고 <code class="language-plaintext highlighter-rouge">/etc/apt/sources.list.d</code> 구성이 진실입니다.</li>
  <li>Windows면 “어떤 zip을 어디서 받았는지”가 패키지 매니저 역할을 대신합니다.</li>
</ul>

<p>3) repository pool과 source artifacts(감사/재현 증거)</p>
<ul>
  <li>nginx.org Debian pool에는 <code class="language-plaintext highlighter-rouge">.dsc</code>, <code class="language-plaintext highlighter-rouge">.debian.tar.xz</code>, <code class="language-plaintext highlighter-rouge">.orig.tar.gz</code>가 함께 올라옵니다.<sup id="fnref:3:2" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
  <li>이 조합은 “이 바이너리가 어떤 upstream 소스(1.31.8) 위에 어떤 packaging 변경을 얹었는지”를 재현하는 기반입니다.</li>
</ul>

<p>4) 문서(의도/권장사항)</p>
<ul>
  <li>문서는 최신 상태가 아닐 수 있고, 버전 문자열을 운영에서 바로 쓰기엔 정보가 부족합니다.</li>
  <li>다만 Windows 문서처럼 “mainline을 받아라, known fixes는 mainline에 있다” 같은 정책성 문장은 기준선 합의에 도움이 됩니다.<sup id="fnref:5:2" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
</ul>

<p>이 우선순위로 잡으면, 문서와 레포의 표기가 조금 흔들려도 “현장 증거 → 배포 증거 → 재현 증거”로 역추적이 됩니다.</p>

<h2 id="debianubuntu에서-nginxorg-mainline-1318로-맞추는-절차">Debian/Ubuntu에서 nginx.org mainline 1.31.8로 맞추는 절차</h2>

<p>여기서는 Debian 12 bookworm을 기준으로 씁니다. 같은 구조로 Ubuntu도 거의 그대로 갑니다(키링 패키지 이름 정도만 다름). nginx.org 공식 Linux packages 문서가 Debian/Ubuntu 설치 절차, 서명키 지문, mainline 레포 URL, pinning까지 한 번에 제공합니다.<sup id="fnref:11:1" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></p>

<h3 id="1-레포와-키를-먼저-고정합니다">1) 레포와 키를 먼저 고정합니다</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Debian 12 bookworm</span>
<span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> curl gnupg2 ca-certificates lsb-release debian-archive-keyring

<span class="c"># nginx 서명키를 키링으로 저장</span>
curl https://nginx.org/keys/nginx_signing.key | gpg <span class="nt">--dearmor</span> <span class="se">\</span>
  | <span class="nb">sudo tee</span> /usr/share/keyrings/nginx-archive-keyring.gpg <span class="o">&gt;</span>/dev/null

<span class="c"># 지문 확인(문서에 나온 fingerprint가 보여야 함)</span>
gpg <span class="nt">--dry-run</span> <span class="nt">--quiet</span> <span class="nt">--no-keyring</span> <span class="nt">--import</span> <span class="nt">--import-options</span> import-show <span class="se">\</span>
  /usr/share/keyrings/nginx-archive-keyring.gpg
</code></pre></div></div>

<p>nginx.org 문서는 fingerprint <code class="language-plaintext highlighter-rouge">573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62</code>가 출력에 포함돼야 한다고 명시합니다.<sup id="fnref:11:2" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></p>

<p>다음으로 mainline 레포를 설정합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># mainline 레포를 명시적으로 사용</span>
<span class="nb">echo</span> <span class="s2">"deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] </span><span class="se">\</span><span class="s2">
  https://nginx.org/packages/mainline/debian </span><span class="si">$(</span>lsb_release <span class="nt">-cs</span><span class="si">)</span><span class="s2"> nginx"</span> <span class="se">\</span>
  | <span class="nb">sudo tee</span> /etc/apt/sources.list.d/nginx.list

<span class="c"># pinning으로 nginx.org 우선</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"Package: *</span><span class="se">\n</span><span class="s2">Pin: origin nginx.org</span><span class="se">\n</span><span class="s2">Pin: release o=nginx</span><span class="se">\n</span><span class="s2">Pin-Priority: 900</span><span class="se">\n</span><span class="s2">"</span> <span class="se">\</span>
  | <span class="nb">sudo tee</span> /etc/apt/preferences.d/99nginx

<span class="nb">sudo </span>apt update
</code></pre></div></div>

<p>이 pinning 블록은 nginx.org 문서에 그대로 있는 형태입니다.<sup id="fnref:11:3" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></p>

<p>여기까지를 “버전 진실의 기준선 공사”라고 봅니다. 업그레이드 자체보다 이 단계가 더 중요합니다.</p>

<h3 id="2-candidate가-실제로-1318-1bookworm인지-확인합니다">2) Candidate가 실제로 1.31.8-1~bookworm인지 확인합니다</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt-cache policy nginx | <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'1,120p'</span>
</code></pre></div></div>

<p>예상 출력의 형태는 이런 식입니다(버전 숫자만 확인하면 됩니다).</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nginx:
  Installed: 1.31.7-1~bookworm
  Candidate: 1.31.8-1~bookworm
  Version table:
     1.31.8-1~bookworm 900
        900 https://nginx.org/packages/mainline/debian bookworm/nginx amd64 Packages
 *** 1.31.7-1~bookworm 100
        100 /var/lib/dpkg/status
</code></pre></div></div>

<p>여기서 Candidate가 배포판 기본 nginx(예: 1.22.x)로 보이면, 소스 리스트가 섞였거나 pinning이 적용되지 않은 상태입니다. Debian bookworm의 nginx가 1.22.1 계열이라는 건 공개 소스 목록에서도 확인됩니다.<sup id="fnref:6:1" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></p>

<h3 id="3-업그레이드는-버전을-명시해서-합니다">3) 업그레이드는 버전을 명시해서 합니다</h3>

<p>운영에서는 “최신으로 올려”가 아니라 “이 버전으로 올려”가 안전합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> <span class="nv">nginx</span><span class="o">=</span>1.31.8-1~bookworm

<span class="c"># 버전 확인(패키지 버전)</span>
dpkg <span class="nt">-s</span> nginx | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'^Version:'</span>

<span class="c"># 버전 확인(upstream 버전)</span>
nginx <span class="nt">-v</span>

<span class="c"># 빌드 옵션 확인(모듈/컴파일 옵션 차이 때문에 -V를 남기는 게 좋음)</span>
nginx <span class="nt">-V</span> 2&gt;&amp;1 | <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'1,80p'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">nginx -v</code>와 <code class="language-plaintext highlighter-rouge">dpkg -s</code>가 서로 다른 문자열을 찍는 건 정상입니다. <code class="language-plaintext highlighter-rouge">nginx -v</code>는 upstream 버전(1.31.8)을 말하고, <code class="language-plaintext highlighter-rouge">dpkg</code>는 패키지 버전(<code class="language-plaintext highlighter-rouge">1.31.8-1~bookworm</code>)을 말합니다. 둘 다 기록해야 “버전 진실”이 됩니다.</p>

<h3 id="4-업그레이드-검증은--t--t를-자동화-포인트로-삼습니다">4) 업그레이드 검증은 <code class="language-plaintext highlighter-rouge">-t</code>, <code class="language-plaintext highlighter-rouge">-T</code>를 자동화 포인트로 삼습니다</h3>

<p>nginx의 커맨드라인 스위치는 <code class="language-plaintext highlighter-rouge">-t</code>(config test), <code class="language-plaintext highlighter-rouge">-T</code>(config dump 포함), <code class="language-plaintext highlighter-rouge">-v</code>, <code class="language-plaintext highlighter-rouge">-V</code>를 공식 문서에서 정의합니다.<sup id="fnref:7:3" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 설정 문법 + 참조 파일 open까지 확인</span>
<span class="nb">sudo </span>nginx <span class="nt">-t</span>

<span class="c"># 운영에서 자주 필요한 것: 실제 include까지 펼친 결과를 아카이빙</span>
<span class="nb">sudo </span>nginx <span class="nt">-T</span> 2&gt;/tmp/nginx-T.stderr | <span class="nb">tee</span> /tmp/nginx-T.stdout <span class="o">&gt;</span>/dev/null

<span class="nb">sha256sum</span> /tmp/nginx-T.stdout /tmp/nginx-T.stderr
</code></pre></div></div>

<p>내 경우, 업그레이드 검증의 “최소 증거 묶음”을 아래 4개로 잡습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">dpkg -s nginx</code> 출력</li>
  <li><code class="language-plaintext highlighter-rouge">nginx -V</code> 출력</li>
  <li><code class="language-plaintext highlighter-rouge">nginx -T</code> 출력(표준출력/표준에러)</li>
  <li><code class="language-plaintext highlighter-rouge">/var/log/nginx/error.log</code>의 업그레이드 직후 5분 구간</li>
</ul>

<p>이 정도면 장애가 났을 때 재현/역추적이 됩니다.</p>

<h2 id="nginx-1318에서-놓치기-쉬운-변경-working_directory-상대-경로">nginx 1.31.8에서 놓치기 쉬운 변경: <code class="language-plaintext highlighter-rouge">working_directory</code> 상대 경로</h2>

<p>버전 진실만 맞추고 넘어가면 안 되는 게 “작동 방식 변경”입니다. 이번 1.31.8 CHANGES에는 <code class="language-plaintext highlighter-rouge">working_directory</code>의 해석이 바뀌었다는 항목이 있습니다.</p>

<ul>
  <li>1.31.8: <code class="language-plaintext highlighter-rouge">working_directory</code>가 “현재 디렉터리”가 아니라 “nginx prefix 디렉터리” 기준 상대 경로가 됐다고 적혀 있습니다.<sup id="fnref:12" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup></li>
  <li>그리고 잘못된 디렉터리를 지정했을 때 worker가 start/reconfigure 시점에 종료할 수 있는 버그픽스도 같은 덩어리로 들어가 있습니다.<sup id="fnref:12:1" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup></li>
</ul>

<p>운영에서 이게 왜 중요하냐면, 다음 패턴이 있기 때문입니다.</p>

<ul>
  <li>systemd unit이나 container entrypoint가 어떤 작업 디렉터리에서 nginx를 띄우는지 사람마다 다르게 이해하는 경우</li>
  <li><code class="language-plaintext highlighter-rouge">working_directory</code>를 상대 경로로 쓰고 “어차피 여기서 실행하니까”라고 생각해 둔 설정</li>
</ul>

<p>이 변경은 “버전 숫자만 올리면 되는” 작업이 아니라 “설정 의미가 바뀌는” 업그레이드입니다. 따라서 업그레이드 검증 플로우에 <code class="language-plaintext highlighter-rouge">nginx -T</code> 결과에서 prefix/working_directory 관련 라인을 점검하는 단계를 넣는 게 맞습니다.</p>

<p>예를 들면:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>nginx <span class="nt">-T</span> 2&gt;&amp;1 | <span class="nb">grep</span> <span class="nt">-nE</span> <span class="s1">'^(# )?(prefix|working_directory)'</span>
</code></pre></div></div>

<p>이건 기능 추가보다 더 위험한 유형의 변경입니다. 릴리스 노트를 자동으로 파싱해서 “의미 변경(Change)” 항목이 있으면 검증 티켓에 자동으로 링크를 붙이는 쪽이 낫습니다. CHANGES는 nginx.org에서 텍스트로 제공됩니다.<sup id="fnref:12:2" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup></p>

<h2 id="origtargz로-upstream과-패키지를-연결하는-방법재현-가능한-증거-만들기"><code class="language-plaintext highlighter-rouge">orig.tar.gz</code>로 upstream과 패키지를 연결하는 방법(재현 가능한 증거 만들기)</h2>

<p>nginx.org Debian pool이 유용한 이유는, 바이너리 <code class="language-plaintext highlighter-rouge">.deb</code>만 주는 게 아니라 Debian 소스 패키지의 3종 세트를 같이 주기 때문입니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">nginx_1.31.8.orig.tar.gz</code></li>
  <li><code class="language-plaintext highlighter-rouge">nginx_1.31.8-1~bookworm.debian.tar.xz</code></li>
  <li><code class="language-plaintext highlighter-rouge">nginx_1.31.8-1~bookworm.dsc</code></li>
</ul>

<p>이 파일들이 실제로 2026-10-21 18:26로 올라와 있는 걸 인덱스에서 확인할 수 있습니다.<sup id="fnref:3:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>운영팀 관점에서 이걸 왜 확인하냐면, 취약점 대응에서 자주 이런 질문이 나오기 때문입니다.</p>

<ul>
  <li>“패키지에 포함된 소스가 정말 upstream 1.31.8이 맞나?”</li>
  <li>“우리 패키지에는 downstream 패치가 얼마나 들어가 있나?”</li>
  <li>“감사/포렌식 때 재현 가능한가?”</li>
</ul>

<p>Debian 계열이라면 아래처럼 로컬에서 소스 패키지를 풀어볼 수 있습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> dpkg-dev

<span class="nv">WORK</span><span class="o">=</span>/tmp/nginx-src-1.31.8
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$WORK</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">cd</span> <span class="s2">"</span><span class="nv">$WORK</span><span class="s2">"</span>

<span class="c"># nginx.org pool에서 소스 패키지 3종을 받는다</span>
curl <span class="nt">-fsSLO</span> https://nginx.org/packages/mainline/debian/pool/nginx/n/nginx/nginx_1.31.8.orig.tar.gz
curl <span class="nt">-fsSLO</span> https://nginx.org/packages/mainline/debian/pool/nginx/n/nginx/nginx_1.31.8-1~bookworm.debian.tar.xz
curl <span class="nt">-fsSLO</span> https://nginx.org/packages/mainline/debian/pool/nginx/n/nginx/nginx_1.31.8-1~bookworm.dsc

<span class="c"># dsc를 기준으로 소스를 전개한다</span>
<span class="nb">rm</span> <span class="nt">-rf</span> nginx-1.31.8
<span class="nv">DPKG_COLORS</span><span class="o">=</span>never dpkg-source <span class="nt">-x</span> nginx_1.31.8-1~bookworm.dsc

<span class="c"># 어떤 파일이 들어왔는지 확인</span>
<span class="nb">ls</span> <span class="nt">-al</span>
<span class="nb">ls</span> <span class="nt">-al</span> nginx-1.31.8 | <span class="nb">head</span>
</code></pre></div></div>

<p>예상 결과는 이런 형태입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nginx_1.31.8-1~bookworm.dsc
nginx_1.31.8-1~bookworm.debian.tar.xz
nginx_1.31.8.orig.tar.gz
nginx-1.31.8/
</code></pre></div></div>

<p>여기서 한 단계 더 들어가면, upstream tarball(<code class="language-plaintext highlighter-rouge">nginx-1.31.8.tar.gz</code>)과 Debian source의 <code class="language-plaintext highlighter-rouge">orig.tar.gz</code>가 동일한지 해시로 비교할 수 있습니다. nginx.org download 페이지가 mainline tarball 엔트리를 제공한다는 사실은 확인 가능하지만, 실제 해시 비교는 운영 환경에서 직접 다운로드해 수행해야 합니다.<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># upstream tarball을 따로 받아서(운영 환경에서) 비교한다고 가정</span>
<span class="nb">sha256sum </span>nginx_1.31.8.orig.tar.gz nginx-1.31.8.tar.gz
</code></pre></div></div>

<p>두 해시가 같으면 “upstream 소스와 packaging orig가 동일”하다는 증거가 됩니다. 다르면 그 차이를 먼저 이해해야 합니다(재패키징, 압축 옵션 차이, 또는 완전히 다른 소스).</p>

<p>추가로, nginx.org는 패키징 소스가 별도의 GitHub 저장소에 있고, mainline은 <code class="language-plaintext highlighter-rouge">master</code> 브랜치가 현재 mainline 패키징을 담는다고 문서에 적습니다.<sup id="fnref:11:4" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup> 이건 “패키지 빌드 옵션이 upstream 기본값과 다를 수 있다”는 경고이기도 합니다.</p>

<h2 id="windows-배포판에서의-버전-진실-beta라는-전제가-먼저입니다">Windows 배포판에서의 버전 진실: beta라는 전제가 먼저입니다</h2>

<p>Windows에서 nginx를 운영에 쓰는 팀을 종종 봤고, 대개는 레거시 이유로 어쩔 수 없는 케이스였습니다. 그럴수록 버전 진실을 더 엄격하게 잡아야 합니다.</p>

<p>공식 Windows 문서는 다음을 분명히 합니다.</p>

<ul>
  <li>Win32 API 기반이며 <code class="language-plaintext highlighter-rouge">select()</code>와 <code class="language-plaintext highlighter-rouge">poll()</code>만 쓴다고 적습니다.</li>
  <li>고성능/확장성을 기대하지 말라고 쓰고, Windows 버전은 beta로 간주된다고 명시합니다.<sup id="fnref:5:3" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
</ul>

<p>그리고 설치 단계에서 mainline 최신 배포판(현재 문서에서는 1.31.8)을 받으라고 안내합니다.<sup id="fnref:5:4" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup> download 페이지도 Windows 엔트리를 <code class="language-plaintext highlighter-rouge">nginx/Windows-1.31.8</code>로 제공합니다.<sup id="fnref:2:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>Windows에서 버전 진실을 맞출 때는 “문서에 적힌 숫자”가 아니라 “내가 받은 zip 파일의 출처 + unzip된 디렉터리 + 실행 바이너리 출력”을 세트로 남겨야 합니다.</p>

<h3 id="windows-업그레이드-예시현실적인-기록-포맷">Windows 업그레이드 예시(현실적인 기록 포맷)</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 작업 디렉터리</span><span class="w">
</span><span class="nv">$Work</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">TEMP</span><span class="s2">\nginx-1.31.8"</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">Directory</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Work</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
</span><span class="nx">Set-Location</span><span class="w"> </span><span class="nv">$Work</span><span class="w">

</span><span class="c"># 다운로드(공식 download 엔트리에 대응)</span><span class="w">
</span><span class="n">Invoke-WebRequest</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="s2">"https://nginx.org/download/nginx-1.31.8.zip"</span><span class="w"> </span><span class="nt">-OutFile</span><span class="w"> </span><span class="s2">"nginx-1.31.8.zip"</span><span class="w">

</span><span class="c"># 압축 해제</span><span class="w">
</span><span class="n">Expand-Archive</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"nginx-1.31.8.zip"</span><span class="w"> </span><span class="nt">-DestinationPath</span><span class="w"> </span><span class="s2">"C:\"</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="n">Set-Location</span><span class="w"> </span><span class="s2">"C:\nginx-1.31.8"</span><span class="w">

</span><span class="c"># 버전 확인(실행 바이너리 기준)</span><span class="w">
</span><span class="o">.</span><span class="n">\nginx.exe</span><span class="w"> </span><span class="nt">-v</span><span class="w">
</span><span class="o">.</span><span class="n">\nginx.exe</span><span class="w"> </span><span class="nt">-V</span><span class="w">

</span><span class="c"># 기동 및 프로세스 확인(공식 문서 예시와 동일한 흐름)</span><span class="w">
</span><span class="n">Start-Process</span><span class="w"> </span><span class="nt">-FilePath</span><span class="w"> </span><span class="s2">".\nginx.exe"</span><span class="w">
</span><span class="n">tasklist</span><span class="w"> </span><span class="nx">/fi</span><span class="w"> </span><span class="s2">"imagename eq nginx.exe"</span><span class="w">
</span></code></pre></div></div>

<p>Windows 문서는 <code class="language-plaintext highlighter-rouge">tasklist</code>로 master/worker 프로세스를 확인하는 예시를 그대로 싣고 있고, 경로 prefix 동작(실행 위치가 prefix가 됨), 설정 파일 경로 표기(슬래시) 같은 함정도 같이 적습니다.<sup id="fnref:5:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup> 이 문서의 존재 이유가 “Windows는 UNIX와 동일하게 생각하면 안 된다”는 경고라고 봅니다.</p>

<p>Windows의 버전 진실은 특히 단순합니다.</p>

<ul>
  <li>upstream version: zip 파일명에 들어있는 <code class="language-plaintext highlighter-rouge">1.31.8</code></li>
  <li>running binary identity: <code class="language-plaintext highlighter-rouge">nginx.exe -v/-V</code></li>
  <li>package manager가 없으니, 다운로드 URL과 해시를 남겨서 대체</li>
</ul>

<p>zip을 어디서 받았는지와 파일 해시가 없으면, 같은 1.31.8이라도 재현이 안 됩니다.</p>

<h2 id="업그레이드-검증-플로우-운영팀이-합의해야-하는-최소-단계">업그레이드 검증 플로우: 운영팀이 합의해야 하는 최소 단계</h2>

<p>버전 진실을 맞춘다는 건 결국 “업그레이드 검증이 반복 가능하다”는 뜻이어야 합니다. 내가 쓰는 플로우를 nginx에 맞게 정리하면 아래입니다.</p>

<h3 id="1-인벤토리-단계현재-상태를-먼저-증거화">1) 인벤토리 단계(현재 상태를 먼저 증거화)</h3>

<p>Linux(Debian/Ubuntu):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nb">echo</span> <span class="s2">"== package =="</span>
dpkg <span class="nt">-s</span> nginx | <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'1,120p'</span>

<span class="nb">echo</span> <span class="s2">"== binary version =="</span>
nginx <span class="nt">-v</span>
nginx <span class="nt">-V</span> 2&gt;&amp;1 | <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'1,120p'</span>

<span class="nb">echo</span> <span class="s2">"== config test =="</span>
<span class="nb">sudo </span>nginx <span class="nt">-t</span>

<span class="nb">echo</span> <span class="s2">"== config dump hash =="</span>
<span class="nb">sudo </span>nginx <span class="nt">-T</span> 2&gt;/dev/null | <span class="nb">sha256sum</span>
</code></pre></div></div>

<p>Windows:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">nginx.exe -v/-V</code> 출력 저장</li>
  <li><code class="language-plaintext highlighter-rouge">nginx-*.zip</code> 해시 저장</li>
  <li><code class="language-plaintext highlighter-rouge">C:\nginx-*\conf\nginx.conf</code>와 include 파일들 해시 저장</li>
</ul>

<p>여기까지가 없으면 업그레이드는 “변경”이 아니라 “도박”이 됩니다.</p>

<h3 id="2-타깃-정의-단계업stream패키지채널을-함께-고정">2) 타깃 정의 단계(업stream/패키지/채널을 함께 고정)</h3>

<p>예시(이번 케이스):</p>

<ul>
  <li>upstream: 1.31.8</li>
  <li>channel: nginx.org mainline</li>
  <li>Debian package: 1.31.8-1~bookworm</li>
  <li>Windows artifact: nginx-1.31.8.zip</li>
</ul>

<p>이 4개가 한 문장으로 말해지면, 운영 커뮤니케이션이 안정됩니다.</p>

<h3 id="3-아티팩트-검증-단계서명출처">3) 아티팩트 검증 단계(서명/출처)</h3>

<ul>
  <li>Debian/Ubuntu: apt는 repo 메타데이터와 패키지 서명을 검증합니다. nginx.org는 서명키를 키링으로 설치하고 지문을 확인하라고 절차에 포함합니다.<sup id="fnref:11:5" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></li>
  <li>pinning: nginx.org는 배포판 제공 패키지보다 자기 패키지를 우선하라고 pinning까지 제공합니다.<sup id="fnref:11:6" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></li>
</ul>

<p>Windows:</p>
<ul>
  <li>패키지 매니저 서명 검증이 없으니, 다운로드 URL(공식 도메인), 파일 해시, 내부 파일 목록을 남기는 것으로 대체합니다.</li>
</ul>

<h3 id="4-설정런타임-검증-단계">4) 설정/런타임 검증 단계</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">nginx -t</code>는 반드시 자동화합니다.<sup id="fnref:7:4" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></li>
  <li><code class="language-plaintext highlighter-rouge">nginx -T</code> 결과를 저장하고 diff 포인트로 씁니다.<sup id="fnref:7:5" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></li>
  <li>CHANGES의 “Change” 항목은 설정 의미 변경으로 취급합니다. 1.31.8의 <code class="language-plaintext highlighter-rouge">working_directory</code>가 대표 사례입니다.<sup id="fnref:12:3" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup></li>
</ul>

<p>이 부분은 내가 예전에 정리했던 HTTP/3 구성 의존 CVE 대응 체크리스트의 관점(“기능을 켠 부분이 취약점 경계가 된다”)과도 연결됩니다. 설정 의미가 바뀌면 보안 경계도 바뀝니다.</p>

<ul>
  <li><a href="https://daewooki.github.io/posts/nginx-http3-quic-cve-mitigation-checklist/">NGINX HTTP/3 구성 의존 CVE 대응 체크리스트</a></li>
</ul>

<h3 id="5-롤백-가능성-확보가장-자주-빠지는-부분">5) 롤백 가능성 확보(가장 자주 빠지는 부분)</h3>

<p>패키지 기반 운영에서 롤백이 어려운 이유는 보통 하나입니다.</p>

<ul>
  <li>“이전 버전 .deb가 로컬에 남아 있지 않다”</li>
</ul>

<p>nginx.org repo는 fast-moving이고, 운영 환경에 따라서는 과거 버전을 계속 제공한다고 가정하면 위험합니다. 따라서 업그레이드 직전에 “지금 설치된 버전”과 “올릴 버전”의 <code class="language-plaintext highlighter-rouge">.deb</code>를 둘 다 별도 아티팩트 저장소에 보관하는 게 안전합니다.</p>

<p>Debian/Ubuntu에서는 최소한 아래 정도는 해둡니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 현재 설치된 버전을 파일로 확보</span>
apt-get download nginx

<span class="c"># 타깃 버전을 명시해서 확보(예: 1.31.8-1~bookworm)</span>
apt-get download <span class="nv">nginx</span><span class="o">=</span>1.31.8-1~bookworm
</code></pre></div></div>

<p>이걸 안 하면, 긴급 롤백이 “가능한지 불가능한지”부터 확인해야 합니다.</p>

<h2 id="함정과-트레이드오프-버전-진실을-무너뜨리는-패턴">함정과 트레이드오프: 버전 진실을 무너뜨리는 패턴</h2>

<h3 id="1-배포판-nginx와-nginxorg-nginx를-섞는-순간-모듈이-무너집니다">1) 배포판 nginx와 nginx.org nginx를 섞는 순간 모듈이 무너집니다</h3>

<p>Debian bookworm은 nginx가 1.22.1 계열이고, nginx.org mainline은 1.31.8 계열입니다.<sup id="fnref:6:2" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup> 이 차이는 단순히 기능 차이가 아니라 “빌드 옵션, 패치, 모듈 ABI” 차이로 이어질 수 있습니다.</p>

<p>nginx.org는 dynamic module을 별도 패키지로 배송한다고 문서에 적고, 어떤 모듈이 분리되는지도 나열합니다.<sup id="fnref:11:7" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup> 즉, nginx를 nginx.org에서 받으면 모듈도 nginx.org 쪽 패키지 세트로 맞추는 게 기본입니다.</p>

<p>이걸 섞으면 버전 진실이 무너집니다. <code class="language-plaintext highlighter-rouge">nginx -v</code>는 1.31.8을 찍는데, 로드된 <code class="language-plaintext highlighter-rouge">.so</code>는 배포판 빌드 기준으로 빌드된 것일 수 있습니다. 장애는 보통 업그레이드 당일이 아니라 “다음 reload 시점”에 터집니다.</p>

<h3 id="2-nginx--v만-믿으면-보안-티켓이-잘못-닫힙니다">2) <code class="language-plaintext highlighter-rouge">nginx -v</code>만 믿으면 보안 티켓이 잘못 닫힙니다</h3>

<p>취약점 공지는 대개 upstream 버전 기준으로 나옵니다(예: “1.31.7에서 수정”). news도 stable/mainline을 같이 언급하며 CVE를 연결해 공지합니다.<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>그런데 패키지 기반 운영에서는 실제로는 <code class="language-plaintext highlighter-rouge">1.31.7-1~bookworm</code>인지 <code class="language-plaintext highlighter-rouge">1.31.7-2~bookworm</code>인지가 중요할 때가 있습니다(패키징 수정만 들어갔을 수도 있고, 빌드 플래그가 바뀌었을 수도 있음). 이때 <code class="language-plaintext highlighter-rouge">nginx -v</code>는 둘을 구분 못 합니다.</p>

<p>그래서 보안 대응 티켓의 닫힘 조건을 다음처럼 분리해 두는 게 낫습니다.</p>

<ul>
  <li>upstream fix boundary 충족 여부: <code class="language-plaintext highlighter-rouge">nginx -v</code>로 확인</li>
  <li>배포 아티팩트 동일성/재현성: <code class="language-plaintext highlighter-rouge">dpkg -s nginx</code> + repo origin + <code class="language-plaintext highlighter-rouge">.deb</code> 해시로 확인</li>
</ul>

<h3 id="3-windows는-문서의-1318과-실제-zip을-분리해야-합니다">3) Windows는 “문서의 1.31.8”과 “실제 zip”을 분리해야 합니다</h3>

<p>Windows 문서가 1.31.8을 예시로 드는 건 “작성 시점의 최신”을 의미할 뿐, 그 숫자가 규격은 아닙니다.<sup id="fnref:5:6" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup> download 페이지가 실제 배포 엔트리를 갖고 있고, 결국 거기에 있는 파일을 받는 게 배포 진실입니다.<sup id="fnref:2:3" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>Windows 운영에서 가장 자주 보는 실수는 이겁니다.</p>

<ul>
  <li>사내 위키에는 “Windows nginx는 1.31.8”이라고만 적혀 있고</li>
  <li>실제 서버는 누군가 예전에 받아둔 zip을 재사용해서 1.31.6이 떠 있는 상태</li>
</ul>

<p>이건 사람이 나쁜 게 아니라, 패키지 매니저가 없으면 자연스럽게 벌어지는 일입니다. 그래서 Windows는 “다운로드 URL + 파일 해시 + <code class="language-plaintext highlighter-rouge">nginx.exe -V</code> 출력”을 한 묶음으로 남기는 규칙이 필요합니다.</p>

<h2 id="도입-판단-기준-우리-조직의-기준선은-무엇이어야-하나">도입 판단 기준: 우리 조직의 기준선은 무엇이어야 하나</h2>

<p>여기까지를 기반으로 운영팀 기준선을 결정할 때 나는 아래처럼 정리합니다.</p>

<ul>
  <li>mainline을 쓰기로 했다면, 버전 기준선은 문서가 아니라 nginx.org 패키지 레포가 됩니다. 레포 설정(mainline URL)과 pinning이 곧 정책입니다.<sup id="fnref:11:8" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></li>
  <li>배포판 패키지를 쓰기로 했다면, upstream 버전 최신 여부는 포기하고, 대신 배포판 security advisory와 backport 정책을 추적하는 게 정책이 됩니다. Debian bookworm이 nginx 1.22.1 계열이라는 사실은 “우리가 upstream 숫자에 맞춰 움직이기 어렵다”는 신호입니다.<sup id="fnref:6:3" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
  <li>Windows는 beta라는 전제를 깔고, 설치 편의성 때문에 쓰는 범위(개발/테스트/로컬 프록시)와 운영 범위를 분리해야 합니다. 문서가 beta를 명시합니다.<sup id="fnref:5:7" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li>취약점 대응 속도를 올리고 싶다면, 버전 진실을 “upstream/package/running” 3분할로 강제하고, 증거 묶음(<code class="language-plaintext highlighter-rouge">dpkg -s</code>, <code class="language-plaintext highlighter-rouge">nginx -V</code>, <code class="language-plaintext highlighter-rouge">nginx -T</code>, 아티팩트 해시)을 자동 수집하게 만들어야 합니다.</li>
</ul>

<p>내 경우 1.31.8 같은 mainline 업그레이드는 “버전 숫자를 올리는 작업”으로 취급하지 않고 “배포 채널의 정합성을 유지하는 작업”으로 취급합니다. 이 관점으로 바꾸면, tarball/문서/윈도우 zip이 서로 다르게 보이는 문제는 대부분 소음으로 내려가고, 실제 운영 리스크(설정 의미 변경, 모듈 호환성, 롤백 불가능)가 전면으로 올라옵니다.</p>

<h2 id="참고-자료">참고 자료</h2>
<ul>
  <li><a href="https://nginx.org/news.html">nginx news</a></li>
  <li><a href="https://nginx.org/en/download.html">nginx: download</a></li>
  <li><a href="https://nginx.org/en/CHANGES">nginx CHANGES</a></li>
  <li><a href="https://nginx.org/packages/mainline/debian/pool/nginx/n/nginx/">Debian mainline pool index (nginx.org)</a></li>
  <li><a href="https://nginx.org/en/linux_packages.html">nginx: Linux packages</a></li>
  <li><a href="https://nginx.org/en/docs/windows.html">nginx for Windows</a></li>
  <li><a href="https://nginx.org/en/docs/switches.html">Command-line parameters</a></li>
  <li><a href="https://sources.debian.org/src/nginx/">Debian Sources: Package nginx</a></li>
  <li><a href="https://www.debian.org/doc/debian-policy/ch-controlfields">Debian Policy Manual (Version fields and ordering rules)</a></li>
  <li><a href="https://ubuntu.com/project/docs/how-ubuntu-is-made/concepts/version-strings/">Ubuntu documentation: Version string format</a></li>
  <li><a href="https://github.com/nginx/nginx">nginx/nginx GitHub repository (Stable and Mainline binaries 설명)</a></li>
  <li><a href="https://www.unitserver.org/blog.nginx.com/content/nginx-1-16-1-17-released.html">NGINX 1.16 and 1.17 소개 글(메인라인/스테이블 운영 관점 참고)</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://nginx.org/news.html">https://nginx.org/news.html</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://nginx.org/en/download.html">https://nginx.org/en/download.html</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:2:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:2:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://nginx.org/packages/mainline/debian/pool/nginx/n/nginx/">https://nginx.org/packages/mainline/debian/pool/nginx/n/nginx/</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:3:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:3:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://github.com/nginx/nginx">https://github.com/nginx/nginx</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://nginx.org/en/docs/windows.html">https://nginx.org/en/docs/windows.html</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:5:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:5:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:5:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:5:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:5:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:5:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:5:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://sources.debian.org/src/nginx/">https://sources.debian.org/src/nginx/</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:6:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:6:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:6:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://nginx.org/en/docs/switches.html">https://nginx.org/en/docs/switches.html</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:7:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:7:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:7:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:7:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:7:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://www.debian.org/doc/debian-policy/policy.pdf">https://www.debian.org/doc/debian-policy/policy.pdf</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p><a href="https://www.debian.org/doc/debian-policy/ch-controlfields">https://www.debian.org/doc/debian-policy/ch-controlfields</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:10" role="doc-endnote">
      <p><a href="https://ubuntu.com/project/docs/how-ubuntu-is-made/concepts/version-strings/">https://ubuntu.com/project/docs/how-ubuntu-is-made/concepts/version-strings/</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:11" role="doc-endnote">
      <p><a href="https://nginx.org/en/linux_packages.html">https://nginx.org/en/linux_packages.html</a> <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:11:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:11:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:11:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:11:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:11:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:11:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:11:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:11:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a></p>
    </li>
    <li id="fn:12" role="doc-endnote">
      <p><a href="https://nginx.org/en/CHANGES">https://nginx.org/en/CHANGES</a> <a href="#fnref:12" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:12:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:12:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:12:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="Web" /><category term="NGINX" /><category term="nginx" /><category term="debian" /><category term="windows" /><category term="package-management" /><category term="release-engineering" /><category term="vulnerability-response" /><summary type="html"><![CDATA[tarball·리포지토리 패키지·문서·Windows 배포판에서 엇갈리는 nginx 버전을 하나의 기준선으로 합치는 운영 플로우를 정리합니다.]]></summary></entry><entry><title type="html">Node.js 보안 릴리스가 올 때 사내 런타임 정책을 굳히는 방법</title><link href="https://joyuno.github.io/blog/2026/10/27/nodejs-security-release-runtime-policy/" rel="alternate" type="text/html" title="Node.js 보안 릴리스가 올 때 사내 런타임 정책을 굳히는 방법" /><published>2026-10-27T09:57:38+09:00</published><updated>2026-10-27T09:57:38+09:00</updated><id>https://joyuno.github.io/blog/2026/10/27/nodejs-security-release-runtime-policy</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/27/nodejs-security-release-runtime-policy/"><![CDATA[<h2 id="2026-10-21-보안-릴리스-오늘-안에-움직여야-하는-종류의-공지">2026-10-21 보안 릴리스: “오늘 안에 움직여야 하는” 종류의 공지</h2>

<p>Node.js 프로젝트는 2026-10-21(수) 전후로 3개 메이저 라인(22.x, 24.x, 26.x)을 동시에 업데이트하는 보안 릴리스를 예고했고, 실제로 고정 버전이 함께 제시됐습니다: <strong>22.23.4 / 24.22.1 / 26.12.1</strong>.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>여기서 운영팀이 압박을 받는 지점은 단순히 “Node.js를 올려야 한다”가 아닙니다.</p>

<ul>
  <li>조직 안에 22/24/26이 동시에 존재하는 순간, 패치 적용이 “단일 작업”이 아니라 “정책”이 됩니다.</li>
  <li>이번 릴리스는 High가 포함돼서(특히 Brotli decoder OOB write RCE) 서비스 팀이 미루기 어려운 성격입니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>Docker base image, CI의 actions/setup-node, 런타임을 포함한 내부 golden image까지 연쇄로 영향을 받습니다.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
</ul>

<p>내 경우 이런 공지가 나오면 기술 리더가 제일 먼저 해야 하는 일은 “각 팀이 알아서 올리세요”가 아니라, <strong>사내 런타임 정책이 지금 상태로는 보안 릴리스에 버티기 어렵다는 사실을 문서로 확정하는 것</strong>입니다. 그 다음에야 실행 계획이 깔끔해집니다.</p>

<h2 id="어떤-취약점이었나-기능이-아니라-경계에서-터졌다">어떤 취약점이었나: 기능이 아니라 “경계”에서 터졌다</h2>

<p>Node.js 공식 공지(취약점 공지)와 각 라인의 보안 릴리스 노트를 합치면, 이번 패치의 중심은 네 가지 범주로 묶입니다.</p>

<h3 id="1-brotli-decoder-out-of-bounds-write-rce-가능">1) Brotli decoder out-of-bounds write (RCE 가능)</h3>

<p>CVE-2026-111497은 Node.js에 번들된 Brotli decoder에서 malformed input 처리 오류로 내부 버퍼 경계를 넘어 쓰기가 가능한 취약점으로 설명됩니다. 스트리밍 decoder가 format error로 끝내야 하는 케이스를 “입력이 더 필요하다”로 오판해 작은 internal buffer에 누적시키는 식의 문제로 정리돼 있습니다. 이 경계 붕괴가 crash, 메모리 노출, RCE로 이어질 수 있다고 공지합니다.<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>운영 관점에서 이건 “내 서비스가 Brotli를 직접 쓰나?”보다 훨씬 넓게 봐야 합니다.</p>

<ul>
  <li>ingress / API gateway가 client 요청 body를 Brotli로 받아서 upstream에 전달하는 구조</li>
  <li>서버가 Brotli로 압축된 payload(예: object storage에서 가져오는 blob, CDN에서 내려받는 리소스)를 풀어 처리하는 배치</li>
  <li>node:zlib을 직접 호출하지 않아도, 프레임워크/미들웨어가 내부적으로 Brotli를 만지는 경로</li>
</ul>

<p>즉, <strong>압축 해제는 데이터 경계에서 일어나고, 데이터 경계는 공격면으로 직결</strong>합니다.</p>

<h3 id="2-dns-입력의-nul-byte-처리">2) DNS 입력의 NUL byte 처리</h3>

<p>CVE-2026-105482는 dns.resolve*() / dns.reverse()에서 NUL byte를 포함한 호스트명이 C string으로 취급되며 묵시적으로 truncation될 수 있다는 내용입니다. allowlist 검증 후 resolve를 수행하는 애플리케이션이 다른 이름을 조회하게 되는 유형이라, SSRF/우회 계열로 이어질 소지가 큽니다.<sup id="fnref:2:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>이 류의 문제는 “코드가 깔끔하면 안전”이 아니라, 내부 컴포넌트(c-ares, C string) 경계에서 생깁니다. 그래서 런타임 패치가 비용 대비 효과가 좋습니다.</p>

<h3 id="3-tls-hostname--ip-normalization">3) TLS hostname / IP normalization</h3>

<p>이번 릴리스에는 TLS 쪽 High가 여러 개 포함됩니다.</p>

<ul>
  <li>non-canonical IP address 거부(CVE-2026-106069, CVE-2026-106072)</li>
  <li>IP normalization regression fix(CVE-2026-105483)</li>
</ul>

<p>그리고 공지에는 fullwidth digits 같은 비 ASCII 문자로 IP를 표기할 때 인증서 검증이 우회될 수 있다는 설명이 들어가 있습니다. 이건 서비스 코드가 안전해도, 결국 tls.checkServerIdentity()와 그 입력 정규화 경계에서 터진 문제입니다.<sup id="fnref:2:3" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<h3 id="4-runtime-snapshot--sea에서-random-cache가-고정되는-문제">4) runtime snapshot / SEA에서 random cache가 “고정되는” 문제</h3>

<p>CVE-2026-106074는 crypto.randomUUID()/randomInt() 계열이 snapshot/SEA(useSnapshot) 빌드 시점에 entropy cache가 user-land snapshot에 저장돼, 프로세스가 시작될 때 동일한 값 시퀀스를 반복할 수 있다는 내용입니다. 최대 127 UUID와 약 1000개 randomInt 값이 예측 가능해질 수 있다는 설명이 명확합니다.<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이건 “RCE”보다 체감이 덜할 수 있지만, 운영자가 제일 싫어하는 유형입니다.</p>

<ul>
  <li>현장에서 재현이 어렵습니다(특정 빌드 플래그, 특정 빌드 단계 호출, 특정 배포 방식이 결합).</li>
  <li>사고가 나면 형태가 이상합니다(충돌, 중복, idempotency 깨짐, 토큰/세션 유사 충돌).</li>
  <li>로그로 보면 앱 버그처럼 보이기 쉽습니다.</li>
</ul>

<p>즉, 보안 릴리스가 “보안팀의 일”이 아니라 “런타임 정책”의 일인 이유가 여기에 있습니다.</p>

<h2 id="공지에서-읽어야-하는-운영-시그널-의존성-업데이트와-배포-지연">공지에서 읽어야 하는 운영 시그널: 의존성 업데이트와 배포 지연</h2>

<p>이번 공지에는 취약점 목록 외에도 “보안 릴리스에 포함된 dependency 업데이트”가 명시돼 있습니다. c-ares, ca-cert(eMudhra Intermediate CA compromise), OpenSSL(24/22), SQLite(22), Undici 등입니다.<sup id="fnref:2:4" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>여기서 실무적으로 중요한 포인트는 두 가지입니다.</p>

<p>1) 앱이 node:crypto, node:tls를 직접 안 써도 영향이 생깁니다. CA bundle이나 resolver 쪽 업데이트는 전역입니다.
2) 이미지 재빌드 트리거를 “Node 버전만”으로 잡으면 놓칩니다. 예컨대 OpenSSL/CA/Undici를 같이 끌고 가는 릴리스는 런타임이 실질적으로 다른 런타임이 됩니다.<sup id="fnref:2:5" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>또 하나는 “CVE가 즉시 다 공개되지 않을 수 있다”는 점입니다. Node.js SECURITY.md는 보안 릴리스 이후 CVE 공개에 지연이 생길 수 있는 타임라인(리포터 공개, 미공개 시 강제 공개, HackerOne 승인 등)을 설명합니다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<p>이 말은, 내부에서 흔히 하는 실수를 경고합니다.</p>

<ul>
  <li>“CVE 상세가 아직 없으니 급한 건 아닌가 봐요” → 오해입니다.</li>
  <li>“스캐너에 아직 안 잡히니 배포 미뤄도 되겠네요” → 타임라인 상 정상입니다.</li>
</ul>

<p>보안 릴리스 공지의 본문만으로도 “이번 주 안에 반영할 것”이 충분히 정당화됩니다.</p>

<h2 id="22--24--26-라인을-동시에-다룬다는-것의-의미">22 / 24 / 26 라인을 동시에 다룬다는 것의 의미</h2>

<p>이번 공지가 특히 까다로운 이유는 세 라인이 “동시에” 패치됐다는 사실 자체가, 이미 Node.js를 1개 라인으로만 운영하지 않는 조직이 많다는 현실을 반영하기 때문입니다.</p>

<p>다만 2026년 10월의 타이밍은 더 미묘합니다.</p>

<ul>
  <li>24.x는 LTS지만 2026-10-20에 Maintenance로 전환되는 일정입니다.<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li>26.x는 Current 라인이지만 2026-10-28에 LTS 전환(Active LTS start)이 예정돼 있습니다.<sup id="fnref:5:1" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li>22.x는 이미 Maintenance LTS이고 EOL이 2027-04-30입니다.<sup id="fnref:5:2" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
</ul>

<p>여기서 정책 결정을 미루면, 조직은 매번 보안 릴리스 때마다 같은 논쟁을 반복합니다.</p>

<ul>
  <li>“24를 계속 들고 갈까? 26으로 넘어갈까?”</li>
  <li>“22에 남은 레거시는 이번에 어디까지 강제할까?”</li>
  <li>“26을 쓰는 팀은 앞으로도 Current를 계속 타도 되나?”</li>
</ul>

<p>내 결론은 보통 다음처럼 정리됩니다.</p>

<ul>
  <li><strong>production 표준은 LTS 라인 1개로 강제</strong>하는 게 비용이 가장 낮습니다.</li>
  <li>다만 표준 1개를 강제하려면, 조직에 “카나리 라인”이 별도로 있어야 합니다.</li>
  <li>22는 표준이 아니라 “유예(legacy) 라인”으로 분리해야 합니다.</li>
</ul>

<p>즉, 22/24/26을 동시에 운영하는 순간, 런타임을 “버전”이 아니라 “역할(role)”로 나눠야 합니다.</p>

<h2 id="공지--사내-런타임-정책-고정-카나리-롤백의-단위-재정의">공지 → 사내 런타임 정책: 고정, 카나리, 롤백의 단위 재정의</h2>

<p>런타임 정책을 제대로 만들려면, 먼저 고정/카나리/롤백의 단위를 명시해야 합니다. 여기서 단위를 잘못 잡으면 운영이 바로 부서집니다.</p>

<h3 id="고정pin의-단위-majorminor가-아니라-patch--build-artifact">고정(pin)의 단위: major/minor가 아니라 patch + build artifact</h3>

<p>보안 릴리스가 patch로 나오면, 흔히 “22.x면 돼요” 같은 표현이 나오는데, 운영 단위로는 부족합니다.</p>

<ul>
  <li>Node.js 공식이 특정 fixed 버전을 지정했습니다(22.23.4, 24.22.1, 26.12.1).<sup id="fnref:2:6" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>컨테이너 환경에서는 같은 Node 버전이라도 base OS, CA bundle, OpenSSL 빌드가 다르면 런타임이 실질적으로 달라집니다.</li>
</ul>

<p>그래서 고정은 최소 두 층으로 갑니다.</p>

<p>1) <strong>Node 버전 고정</strong>: 24.22.1 같은 patch까지 고정
2) <strong>이미지 digest 고정</strong>: node:24.22.1 같은 태그가 아니라 @sha256 digest 고정</p>

<p>태그 고정만 하면, 공급망 관점에서는 “내가 오늘 빌드한 node:24.22.1”과 “한 달 뒤에 pull한 node:24.22.1”이 다를 수 있습니다(재빌드/리베이스/메타데이터 갱신). digest로 내려가야 롤백도 같은 단위가 됩니다.</p>

<h3 id="카나리canary의-단위-트래픽이-아니라-런타임-풀">카나리(canary)의 단위: “트래픽”이 아니라 “런타임 풀”</h3>

<p>Node 런타임 업데이트의 카나리는 트래픽 분할만으로는 부족합니다. 이유는 간단합니다.</p>

<ul>
  <li>Native addon이 있는 서비스는 런타임 차이로 동작이 갈립니다.</li>
  <li>TLS/DNS처럼 외부 경계가 바뀌는 패치는, 트래픽 1%에도 장애를 만들 수 있습니다.</li>
</ul>

<p>그래서 카나리는 “런타임 풀”로 정의하는 편이 실용적입니다.</p>

<ul>
  <li>동일 서비스라도 두 개의 Deployment(또는 ASG)를 둡니다.
    <ul>
      <li>stable 풀: 현재 표준 런타임 (예: 24.22.0 → 24.22.1)</li>
      <li>canary 풀: 다음 런타임 (예: 24.22.1, 또는 26.12.1)</li>
    </ul>
  </li>
  <li>트래픽은 나중 문제입니다. 먼저 <strong>런타임이 살아남는지</strong>를 봅니다.
    <ul>
      <li>startup, health check, TLS handshake, DNS lookup, 압축 해제 경로</li>
    </ul>
  </li>
</ul>

<h3 id="롤백rollback의-단위-앱-버전이-아니라-런타임--앱">롤백(rollback)의 단위: “앱 버전”이 아니라 “런타임 + 앱”</h3>

<p>이번처럼 런타임 취약점 패치가 High를 포함하면, 보안팀은 빠른 패치를 요구합니다. 반면 서비스팀은 장애를 피하려고 롤백을 원합니다.</p>

<p>이때 롤백 단위를 “앱 이미지 태그”로만 잡으면 정치적 충돌이 생깁니다.</p>

<ul>
  <li>서비스팀: “앱만 롤백하면 되죠”</li>
  <li>보안팀: “런타임 취약점은 그대로인데요”</li>
</ul>

<p>그래서 롤백은 두 단계로 정의합니다.</p>

<ul>
  <li>L1 롤백: 같은 fixed Node 라인 안에서의 롤백(예: 24.22.1 이미지에서 앱만 롤백)</li>
  <li>L2 롤백: fixed Node 이전으로의 롤백(예: 24.22.0으로 후퇴)</li>
</ul>

<p>그리고 L2 롤백은 예외로 남겨야 합니다. 보안 릴리스의 목적은 L2 롤백을 쓰지 않아도 되는 구조를 만드는 데 있습니다.</p>

<h2 id="docker이미지-체인에서-실제로-벌어지는-일">Docker/이미지 체인에서 실제로 벌어지는 일</h2>

<h3 id="공식-node-이미지가-어떤-방식으로-node를-끌어오는지">공식 node 이미지가 어떤 방식으로 Node를 끌어오는지</h3>

<p>Docker Hub의 공식 node 이미지 레이어를 보면, 이미지는 내부에서 NODE_VERSION을 지정한 뒤 nodejs.org 배포 파일을 내려받고, SHASUMS256 서명 파일을 gpg로 검증한 다음 tarball checksum을 검증하고 설치합니다.<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>이 구조는 운영 정책에 중요한 함의를 줍니다.</p>

<ul>
  <li>upstream(Node.js) 보안 릴리스가 나오면, official image도 따라가야 합니다.</li>
  <li>하지만 official image 반영까지는 약간의 지연이 생길 수 있습니다(배포/PR/빌드 파이프라인). 이번 케이스도 docker-library 공식 이미지 업데이트 PR이 별도로 존재합니다.<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
</ul>

<p>지연이 “며칠”이면 운영자가 자체 빌드로 우회해야 하지만, “몇 시간”이면 표준 체계를 유지하는 편이 대체로 이득입니다. 중요한 건 지연 자체가 아니라, 지연을 견디는 정책(카나리, 롤백, 재빌드 트리거)입니다.</p>

<h3 id="docker-tag-전략-node26-node2612-node26121의-차이">Docker tag 전략: node:26, node:26.12, node:26.12.1의 차이</h3>

<p>태그는 보통 세 계층으로 나뉩니다.</p>

<ul>
  <li>floating major: <code class="language-plaintext highlighter-rouge">node:26</code></li>
  <li>floating minor: <code class="language-plaintext highlighter-rouge">node:26.12</code></li>
  <li>pinned patch: <code class="language-plaintext highlighter-rouge">node:26.12.1</code></li>
</ul>

<p>Docker Hub 메타데이터 기준으로도 node:26.12.1 이미지는 다양한 별칭(26, 26.12, 26.12.1 등)으로 연결됩니다.<sup id="fnref:3:2" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>운영 정책에서 나는 다음 원칙을 씁니다.</p>

<ul>
  <li>production은 <code class="language-plaintext highlighter-rouge">node:24.22.1</code>처럼 patch까지 명시하고, 가능하면 digest로 고정합니다.</li>
  <li>canary는 <code class="language-plaintext highlighter-rouge">node:26.12</code>처럼 minor floating을 일부 허용할 수 있지만, 이는 “런타임 풀 카나리”가 있다는 전제에서만 성립합니다.</li>
  <li>개발자 로컬은 major만 고정해도 되지만, CI는 production과 맞추는 게 장애 비용을 낮춥니다.</li>
</ul>

<h2 id="sbom이미지-재빌드-트리거를-어디에-걸-것인가">SBOM/이미지 재빌드 트리거를 어디에 걸 것인가</h2>

<p>SBOM을 진지하게 운영하는 팀이 겪는 문제는 “SBOM을 만들 수 있나”가 아니라 “언제 다시 만들 것인가”입니다.</p>

<p>이번 공지처럼 런타임 자체가 바뀌는 이벤트는 트리거가 명확합니다. 문제는 그 아래 레이어입니다.</p>

<h3 id="트리거-1-nodejs-보안-릴리스-공지vulnerability-blog">트리거 1) Node.js 보안 릴리스 공지(vulnerability blog)</h3>

<p>Node.js는 보안 릴리스를 vulnerability 블로그로 공지합니다. 이 페이지가 변하면 런타임 정책이 변한 겁니다.<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<ul>
  <li>트리거: 새 advisory 게시 또는 기존 advisory 업데이트</li>
  <li>액션: 런타임 이미지 rebuild + SBOM regenerate + canary rollout</li>
</ul>

<h3 id="트리거-2-release-note각-라인의-fixed-버전">트리거 2) release note(각 라인의 fixed 버전)</h3>

<p>조직이 여러 라인을 동시에 운영하면 “이번 fixed 버전이 정확히 뭔지”가 정책의 기준선입니다. 26.12.1/24.22.1/22.23.4 릴리스 노트는 그것을 가장 명확히 제공합니다.<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></p>

<ul>
  <li>트리거: 해당 라인의 security release publish</li>
  <li>액션: 이미지 재빌드, 최소 smoke test, staged rollout</li>
</ul>

<h3 id="트리거-3-docker-official-image-갱신태그는-같아도-digest가-바뀜">트리거 3) Docker official image 갱신(태그는 같아도 digest가 바뀜)</h3>

<p>다운스트림 팀이 <code class="language-plaintext highlighter-rouge">FROM node:24.22.1-bookworm-slim</code>를 쓰는지, 아니면 내부에서 base를 한번 감싼 <code class="language-plaintext highlighter-rouge">company/node-runtime:24.22.1</code>을 쓰는지에 따라 달라집니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">FROM node:24.22.1...</code> 직접 사용
    <ul>
      <li>트리거: upstream tag push / digest 변경</li>
      <li>액션: 모든 downstream 이미지 rebuild</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">company/node-runtime</code>을 운영
    <ul>
      <li>트리거: Node fixed 버전 변경, 또는 base OS/CA/OpenSSL 정책 변경</li>
      <li>액션: runtime base rebuild 후 앱 이미지는 “자동 rebase”</li>
    </ul>
  </li>
</ul>

<p>이번 릴리스는 docker-library official-images 쪽에도 “Node: feat: Node.js 22.23.4, 24.22.1, 26.12.1” 같은 형태로 반영 이벤트가 존재합니다.<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup></p>

<h3 id="트리거-4-ci-런타임actionssetup-node-업데이트">트리거 4) CI 런타임(actions/setup-node) 업데이트</h3>

<p>CI가 old Node로 돌아가면, production에서만 터지는 문제가 생깁니다. 이번 릴리스는 actions/node-versions에도 반영돼 릴리스가 잡혀 있습니다.<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup></p>

<ul>
  <li>트리거: actions/node-versions에 새로운 Node patch가 추가</li>
  <li>액션: CI matrix 업데이트, 캐시 키 재계산(node 버전 포함), 재실행</li>
</ul>

<h2 id="기능팀이-실행-가능한-형태로-정책을-내려주는-방법-최소-코드">“기능팀이 실행 가능한” 형태로 정책을 내려주는 방법: 최소 코드</h2>

<p>정책 문서만으로는 팀이 안 움직입니다. 코드로 강제할 포인트가 있어야 합니다.</p>

<p>여기서는 (1) 런타임 이미지를 자체적으로 만들 때의 최소 예시 (2) 런타임 버전이 맞지 않으면 프로세스가 뜨지 않게 하는 가드 두 개를 남깁니다.</p>

<h3 id="1-공식-tarball--shasums256-서명-검증으로-node-런타임-이미지-만들기">1) 공식 tarball + SHASUMS256 서명 검증으로 Node 런타임 이미지 만들기</h3>

<p>공식 node 이미지가 하는 방식(배포 tarball 다운로드, SHASUMS256 서명 검증, checksum 검증)을 거의 그대로 가져오면, “Docker Hub 공식 태그 반영 지연”이 있어도 내부 이미지를 먼저 만들 수 있습니다. 공식 이미지가 실제로 gpg 키를 가져오고 SHASUMS256.txt.asc를 검증하는 흐름은 레이어에서 확인됩니다.<sup id="fnref:3:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>아래 예시는 linux/amd64 기준이며, Docker BuildKit 환경에서 그대로 빌드 가능합니다.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Dockerfile</span>
<span class="k">FROM</span><span class="s"> debian:bookworm-slim</span>

<span class="k">ARG</span><span class="s"> NODE_VERSION=24.22.1</span>
<span class="k">ARG</span><span class="s"> TARGETARCH=amd64</span>

<span class="k">RUN </span>apt-get update <span class="se">\
</span>  <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="nt">--no-install-recommends</span> <span class="se">\
</span>       ca-certificates curl gnupg xz-utils <span class="se">\
</span>  <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists/<span class="k">*</span>

<span class="c"># Node.js release keys (공식 이미지가 가져오는 키 목록을 그대로 사용)</span>
<span class="c"># 필요하면 조직 정책에 따라 키링을 별도 artifact로 고정해도 됩니다.</span>
<span class="k">RUN </span><span class="nb">set</span> <span class="nt">-eux</span><span class="p">;</span> <span class="se">\
</span>  <span class="nb">export </span><span class="nv">GNUPGHOME</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">mktemp</span> <span class="nt">-d</span><span class="si">)</span><span class="s2">"</span><span class="p">;</span> <span class="se">\
</span>  <span class="k">for </span>key <span class="k">in</span> <span class="se">\
</span>    5BE8A3F6C8A5C01D106C0AD820B1A390B168D356 <span class="se">\
</span>    DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 <span class="se">\
</span>    CC68F5A3106FF448322E48ED27F5E38D5B0A215F <span class="se">\
</span>    890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 <span class="se">\
</span>    C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C <span class="se">\
</span>    108F52B48DB57BB0CC439B2997B01419BD92F80A <span class="se">\
</span>    655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD <span class="se">\
</span>    A363A499291CBBC940DD62E41F10027AF002F8B0 <span class="se">\
</span>  <span class="p">;</span> <span class="k">do</span> <span class="se">\
</span>    gpg <span class="nt">--batch</span> <span class="nt">--keyserver</span> hkps://keys.openpgp.org <span class="nt">--recv-keys</span> <span class="s2">"</span><span class="nv">$key</span><span class="s2">"</span> <span class="se">\
</span>      <span class="o">||</span> gpg <span class="nt">--batch</span> <span class="nt">--keyserver</span> keyserver.ubuntu.com <span class="nt">--recv-keys</span> <span class="s2">"</span><span class="nv">$key</span><span class="s2">"</span><span class="p">;</span> <span class="se">\
</span>  <span class="k">done</span><span class="p">;</span> <span class="se">\
</span>  <span class="nb">arch</span><span class="o">=</span><span class="s2">"x64"</span><span class="p">;</span> <span class="se">\
</span>  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$TARGETARCH</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"arm64"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nb">arch</span><span class="o">=</span><span class="s2">"arm64"</span><span class="p">;</span> <span class="k">fi</span><span class="p">;</span> <span class="se">\
</span>  curl <span class="nt">-fsSLO</span> <span class="nt">--compressed</span> <span class="s2">"https://nodejs.org/dist/v</span><span class="k">${</span><span class="nv">NODE_VERSION</span><span class="k">}</span><span class="s2">/node-v</span><span class="k">${</span><span class="nv">NODE_VERSION</span><span class="k">}</span><span class="s2">-linux-</span><span class="k">${</span><span class="nv">arch</span><span class="k">}</span><span class="s2">.tar.xz"</span><span class="p">;</span> <span class="se">\
</span>  curl <span class="nt">-fsSLO</span> <span class="nt">--compressed</span> <span class="s2">"https://nodejs.org/dist/v</span><span class="k">${</span><span class="nv">NODE_VERSION</span><span class="k">}</span><span class="s2">/SHASUMS256.txt.asc"</span><span class="p">;</span> <span class="se">\
</span>  gpg <span class="nt">--batch</span> <span class="nt">--decrypt</span> <span class="nt">--output</span> SHASUMS256.txt SHASUMS256.txt.asc<span class="p">;</span> <span class="se">\
</span>  <span class="nb">grep</span> <span class="s2">" node-v</span><span class="k">${</span><span class="nv">NODE_VERSION</span><span class="k">}</span><span class="s2">-linux-</span><span class="k">${</span><span class="nv">arch</span><span class="k">}</span><span class="s2">.tar.xz$"</span> SHASUMS256.txt | <span class="nb">sha256sum</span> <span class="nt">-c</span> -<span class="p">;</span> <span class="se">\
</span>  <span class="nb">tar</span> <span class="nt">-xJf</span> <span class="s2">"node-v</span><span class="k">${</span><span class="nv">NODE_VERSION</span><span class="k">}</span><span class="s2">-linux-</span><span class="k">${</span><span class="nv">arch</span><span class="k">}</span><span class="s2">.tar.xz"</span> <span class="nt">-C</span> /usr/local <span class="nt">--strip-components</span><span class="o">=</span>1 <span class="nt">--no-same-owner</span><span class="p">;</span> <span class="se">\
</span>  <span class="nb">ln</span> <span class="nt">-s</span> /usr/local/bin/node /usr/local/bin/nodejs<span class="p">;</span> <span class="se">\
</span>  node <span class="nt">--version</span><span class="p">;</span> npm <span class="nt">--version</span><span class="p">;</span> <span class="se">\
</span>  <span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$GNUPGHOME</span><span class="s2">"</span> <span class="se">\
</span>         <span class="s2">"node-v</span><span class="k">${</span><span class="nv">NODE_VERSION</span><span class="k">}</span><span class="s2">-linux-</span><span class="k">${</span><span class="nv">arch</span><span class="k">}</span><span class="s2">.tar.xz"</span> <span class="se">\
</span>         SHASUMS256.txt.asc SHASUMS256.txt

<span class="k">CMD</span><span class="s"> ["node", "-e", "console.log(process.version)"]</span>
</code></pre></div></div>

<p>빌드/실행:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker build <span class="nt">-t</span> company-node:24.22.1 <span class="nt">--build-arg</span> <span class="nv">NODE_VERSION</span><span class="o">=</span>24.22.1 <span class="nb">.</span>
docker run <span class="nt">--rm</span> company-node:24.22.1
</code></pre></div></div>

<p>예상 출력:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>v24.22.1
</code></pre></div></div>

<p>이 패턴의 장점은 세 가지입니다.</p>

<ul>
  <li>upstream이 tarball을 내는 즉시 내부 런타임 이미지를 만들 수 있습니다.</li>
  <li>“우리가 어떤 Node binary를 썼는지”가 SBOM/attestation으로 남기 쉽습니다.</li>
  <li>롤백이 단순해집니다(동일 Dockerfile + NODE_VERSION만 바꾸면 되므로).</li>
</ul>

<p>단점도 있습니다.</p>

<ul>
  <li>key 관리 정책이 필요합니다(키를 신뢰하는 근거, 키 rotate 시 대응).</li>
  <li>multi-arch 지원, Alpine(musl) 같은 변형까지 챙기려면 공식 이미지 수준의 노력이 들어갑니다.</li>
</ul>

<p>조직이 Node를 플랫폼처럼 쓰는 단계라면(다수 팀, 다수 서비스), 이 정도 투자는 충분히 회수됩니다.</p>

<h3 id="2-런타임-버전-가드-프로세스가-뜨더라도-트래픽을-받지-않게-만들기">2) 런타임 버전 가드: 프로세스가 뜨더라도 트래픽을 받지 않게 만들기</h3>

<p>보안 릴리스 주간에 실제로 자주 일어나는 사고는 “어딘가 한 군데만 안 올림”입니다. 그래서 런타임 버전을 런타임이 직접 선언하게 하면 좋습니다.</p>

<p>아래는 단순하지만 효과적인 패턴입니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">EXPECTED_NODE_MAJOR</code> / <code class="language-plaintext highlighter-rouge">EXPECTED_NODE_VERSION</code>를 환경변수로 넣는다.</li>
  <li>시작 시점에 process.version과 비교한다.</li>
  <li>다르면 즉시 crash(또는 readiness를 false).</li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// runtime-guard.mjs</span>
<span class="k">import</span> <span class="nx">process</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">node:process</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">function</span> <span class="nx">fail</span><span class="p">(</span><span class="nx">msg</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="nx">msg</span><span class="p">);</span>
  <span class="nx">process</span><span class="p">.</span><span class="nx">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">expectedMajor</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPECTED_NODE_MAJOR</span><span class="p">;</span> <span class="c1">// e.g. "24"</span>
<span class="kd">const</span> <span class="nx">expectedVersion</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPECTED_NODE_VERSION</span><span class="p">;</span> <span class="c1">// e.g. "v24.22.1"</span>

<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">expectedMajor</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">expectedVersion</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">fail</span><span class="p">(</span><span class="dl">'</span><span class="s1">runtime-guard: EXPECTED_NODE_MAJOR or EXPECTED_NODE_VERSION is required</span><span class="dl">'</span><span class="p">);</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">actual</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">version</span><span class="p">;</span> <span class="c1">// e.g. "v24.22.1"</span>
<span class="kd">const</span> <span class="nx">actualMajor</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">versions</span><span class="p">.</span><span class="nx">node</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">.</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">]);</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">expectedVersion</span> <span class="o">&amp;&amp;</span> <span class="nx">actual</span> <span class="o">!==</span> <span class="nx">expectedVersion</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">fail</span><span class="p">(</span><span class="s2">`runtime-guard: node version mismatch: expected=</span><span class="p">${</span><span class="nx">expectedVersion</span><span class="p">}</span><span class="s2"> actual=</span><span class="p">${</span><span class="nx">actual</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">expectedMajor</span> <span class="o">&amp;&amp;</span> <span class="nx">actualMajor</span> <span class="o">!==</span> <span class="nx">expectedMajor</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">fail</span><span class="p">(</span><span class="s2">`runtime-guard: node major mismatch: expected=</span><span class="p">${</span><span class="nx">expectedMajor</span><span class="p">}</span><span class="s2"> actual=</span><span class="p">${</span><span class="nx">actualMajor</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">}</span>

<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`runtime-guard: ok (</span><span class="p">${</span><span class="nx">actual</span><span class="p">}</span><span class="s2">)`</span><span class="p">);</span>
</code></pre></div></div>

<p>실행:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">EXPECTED_NODE_VERSION</span><span class="o">=</span>v24.22.1 node runtime-guard.mjs
</code></pre></div></div>

<p>이건 기능적으로 단순하지만 운영상 큰 차이를 만듭니다.</p>

<ul>
  <li>“올려야 하는 버전이 무엇인지”가 배포 파이프라인에 박힙니다.</li>
  <li>카나리 풀에는 expectedVersion을 새 값으로 넣고, stable 풀은 이전 값을 유지할 수 있습니다.</li>
  <li>사고가 나면 롤백이 “앱만”이 아니라 “런타임+앱” 단위로 강제됩니다.</li>
</ul>

<h2 id="반론과-회의론-왜-다들-버전-정책에-지치나">반론과 회의론: 왜 다들 ‘버전 정책’에 지치나</h2>

<p>여기까지 읽으면 보통 이런 반론이 나옵니다.</p>

<h3 id="보안-릴리스가-너무-잦아서-따라갈-수-없다">“보안 릴리스가 너무 잦아서 따라갈 수 없다”</h3>

<p>맞는 말입니다. 특히 Node는 런타임 자체도 크고, 번들된 dependency도 많습니다.</p>

<p>하지만 내가 보기엔 이건 “잦다”가 본질이 아닙니다.</p>

<ul>
  <li>한 번의 보안 릴리스가 있을 때, 조직이 받는 비용이 너무 큽니다.</li>
  <li>비용이 큰 이유는 런타임이 서비스별로 제각각이라서입니다.</li>
</ul>

<p>즉, 보안 릴리스 빈도를 탓하기 전에, 조직 내부의 다양성을 줄이는 쪽이 먼저입니다.</p>

<h3 id="우리는-nodezlib로-brotli를-안-쓰는데요">“우리는 node:zlib로 Brotli를 안 쓰는데요”</h3>

<p>취약점 공지는 “node:zlib로 압축을 푸는 애플리케이션”을 전제로 하지만, 실무에서 데이터 경계는 직접 호출보다 넓습니다. 게다가 이번 릴리스는 Brotli만이 아니라 DNS/TLS/crypto snapshot 등도 포함합니다.<sup id="fnref:2:7" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>Brotli만 안 쓰면 된다는 식으로 좁혀 해석하면, 결국 다음 릴리스에서도 같은 논쟁을 반복합니다.</p>

<h3 id="digest-pinning을-하면-자동-업데이트가-안-돼서-더-위험한-것-아닌가요">“digest pinning을 하면 자동 업데이트가 안 돼서 더 위험한 것 아닌가요”</h3>

<p>digest pinning 자체가 안전을 보장하진 않습니다. 다만 운영의 관점에서 digest pinning은 “업데이트를 의도적으로 만들고”, “롤백을 동일 단위로 만들기” 위한 장치입니다.</p>

<ul>
  <li>자동 업데이트는 빠릅니다.</li>
  <li>자동 업데이트는 원인 규명이 느립니다.</li>
</ul>

<p>보안 릴리스에선 빠름이 중요하지만, 장애가 나면 느림이 더 비쌉니다. 그래서 canary와 함께 digest pinning을 씁니다. 둘이 같이 있어야 균형이 맞습니다.</p>

<h2 id="앞으로-지켜볼-것-26의-lts-전환과-표준-런타임의-재선정">앞으로 지켜볼 것: 26의 LTS 전환과 ‘표준 런타임’의 재선정</h2>

<p>2026-10-21 보안 릴리스는 단순 패치가 아니라, 타이밍상 “표준 런타임을 바꿀지”를 강제합니다.</p>

<ul>
  <li>26.x는 2026-10-28에 LTS로 전환될 예정입니다.<sup id="fnref:5:3" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li>24.x는 2026-10-20에 Maintenance로 들어갑니다.<sup id="fnref:5:4" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
</ul>

<p>보통 조직의 표준 런타임은 Active LTS가 편합니다. Maintenance LTS도 안전 패치는 받지만, 변화가 줄어드는 대신(좋은 점), 생태계의 관심도 줄어듭니다(나쁜 점). 이 균형을 어디에 둘지 결정을 미루면, “보안 릴리스 대응”이 아니라 “메이저 업그레이드 압박”으로 돌아옵니다.</p>

<h2 id="지금-주-안에-강제되는-실행-순서내가-쓰는-체크리스트">지금 주 안에 강제되는 실행 순서(내가 쓰는 체크리스트)</h2>

<p>아래 순서는 기능팀/플랫폼팀/보안팀이 동시에 움직이기 쉽게 만드는 데 목적이 있습니다.</p>

<p>1) 영향 범위 인벤토리</p>
<ul>
  <li>production/staging/dev 각각 Node major 분포</li>
  <li>컨테이너: base image가 <code class="language-plaintext highlighter-rouge">node:</code>인지, 내부 base인지</li>
  <li>CI: actions/setup-node 버전 고정 여부</li>
  <li>snapshot/SEA 사용 여부(있으면 CVE-2026-106074를 우선순위로 올림)<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>2) fixed 버전 선언(정책 문장 1줄)</p>
<ul>
  <li>22 라인은 22.23.4 이상</li>
  <li>24 라인은 24.22.1 이상</li>
  <li>26 라인은 26.12.1 이상<sup id="fnref:2:8" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<p>3) 카나리 풀 먼저 적용</p>
<ul>
  <li>런타임만 교체해서 smoke test</li>
  <li>TLS/DNS/압축 해제 경로를 포함한 synthetic check</li>
</ul>

<p>4) stable 풀 staged rollout</p>
<ul>
  <li>digest pinning이면 digest 업데이트 PR로 추적성 확보</li>
  <li>태그 기반이면 build log에 pull된 digest를 반드시 남김</li>
</ul>

<p>5) SBOM 재생성 + 서명/attestation</p>
<ul>
  <li>SBOM을 “만드는 것”보다 “이 시점에 만들었다”를 남김</li>
  <li>런타임 base SBOM과 앱 SBOM을 분리해 저장</li>
</ul>

<p>6) 롤백 계획 확정(L2 롤백 승인권 포함)</p>
<ul>
  <li>L2 롤백은 예외 절차</li>
  <li>예외를 만들었으면, 동일 사고가 반복되지 않도록 원인(런타임 정책/테스트 공백)을 문서로 남김</li>
</ul>

<p>이 과정을 따라가면, 보안 릴리스 대응이 한 번의 이벤트로 끝나지 않고 조직의 런타임 정책으로 남습니다. 결국 남는 건 “업데이트를 했는가”가 아니라, 다음 업데이트 때도 같은 속도로 움직일 수 있는 구조입니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases">Wednesday, October 21, 2026 Security Releases</a></li>
  <li><a href="https://nodejs.org/en/blog/release/v26.12.1/">Node.js 26.12.1 (Current) 릴리스 노트</a></li>
  <li><a href="https://nodejs.org/tr/blog/release/v24.22.1">Node.js 24.22.1 (LTS) 릴리스 노트</a></li>
  <li><a href="https://nodejs.org/pt-br/blog/release/v22.23.4">Node.js 22.23.4 (LTS) 릴리스 노트</a></li>
  <li><a href="https://github.com/nodejs/Release">Node.js Release Working Group: release schedule</a></li>
  <li><a href="https://github.com/nodejs/Release/issues/1152">Release plan – v26.x Current (#1152)</a></li>
  <li><a href="https://github.com/nodejs/node/blob/main/SECURITY.md">nodejs/node SECURITY.md</a></li>
  <li><a href="https://github.com/nodejs/docker-node/actions/workflows/official-pr.yml">docker-node: Create official images PR workflow</a></li>
  <li><a href="https://github.com/docker-library/official-images/actions/runs/39587556228">docker-library/official-images: Node 22.23.4/24.22.1/26.12.1 반영 작업 로그</a></li>
  <li><a href="https://hub.docker.com/layers/library/node/26.12.1/images/sha256-35823c03e725dca8d8cf2483ec17977a430f2ce0f6f68583913d052e99f913a0">Docker Hub node:26.12.1 이미지 레이어</a></li>
  <li><a href="https://github.com/actions/node-versions/releases">actions/node-versions 릴리스(Setup Node 반영)</a></li>
  <li><a href="https://daewooki.github.io/posts/nodejs-runtime-supply-chain-risk/">Node.js 26.8.2 업그레이드를 공급망 위험으로 해석하기</a></li>
  <li><a href="https://daewooki.github.io/posts/node-crypto-parsepkcs12-safe-refactor/">Node.js crypto.parsePKCS12로 PKCS#12 인증서/키 처리 로직 리팩터링</a></li>
</ul>

<p>이번 2026-10-21 릴리스는 취약점 목록보다도, 조직이 런타임을 “버전”이 아니라 “정책”으로 다루지 않으면 매번 같은 비용을 치르게 된다는 사실을 확인시켜 준 사건입니다.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases">https://nodejs.org/en/blog/vulnerability/october-2026-security-releases</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases?trk=public_post_comment-text">https://nodejs.org/en/blog/vulnerability/october-2026-security-releases?trk=public_post_comment-text</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:2:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:2:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:2:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:2:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:2:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:2:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:2:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://hub.docker.com/layers/library/node/26.12.1/images/sha256-35823c03e725dca8d8cf2483ec17977a430f2ce0f6f68583913d052e99f913a0">https://hub.docker.com/layers/library/node/26.12.1/images/sha256-35823c03e725dca8d8cf2483ec17977a430f2ce0f6f68583913d052e99f913a0</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:3:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:3:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://github.com/nodejs/node/blob/main/SECURITY.md">https://github.com/nodejs/node/blob/main/SECURITY.md</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://github.com/nodejs/Release">https://github.com/nodejs/Release</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:5:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:5:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:5:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:5:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://github.com/nodejs/docker-node/actions/workflows/official-pr.yml">https://github.com/nodejs/docker-node/actions/workflows/official-pr.yml</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://nodejs.org/en/blog/release/v26.12.1/">https://nodejs.org/en/blog/release/v26.12.1/</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://github.com/docker-library/official-images/actions/runs/39587556228">https://github.com/docker-library/official-images/actions/runs/39587556228</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p><a href="https://github.com/actions/node-versions/releases">https://github.com/actions/node-versions/releases</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="News" /><category term="Languages" /><category term="nodejs" /><category term="security-release" /><category term="runtime-policy" /><category term="docker-image" /><category term="sbom" /><category term="canary-release" /><summary type="html"><![CDATA[Node.js 22/24/26 동시 보안 릴리스(22.23.4·24.22.1·26.12.1)를 계기로 고정·카나리·롤백, SBOM·이미지 재빌드 트리거를 운영 관점에서 정리합니다.]]></summary></entry><entry><title type="html">2026-10-27 (화) 개발일지</title><link href="https://joyuno.github.io/blog/2026/10/27/dev-blog/" rel="alternate" type="text/html" title="2026-10-27 (화) 개발일지" /><published>2026-10-27T00:00:00+09:00</published><updated>2026-10-27T00:00:00+09:00</updated><id>https://joyuno.github.io/blog/2026/10/27/dev-blog</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/27/dev-blog/"><![CDATA[<p>오늘은 Lepons Cockpit에서 패널 분할 경계를 드래그할 때 크기가 마우스보다 덜 따라오는 버그를 잡았다. 처음엔 마우스 이벤트 핸들러 문제라 생각했는데, 파보니 <strong>2분할만 고려한 계산 로직</strong>이 3분할 이상에서 깨지고 있었다. 리뷰 중 우연히 발견했는데, 고치고 나니 왜 그동안 못 본 건지 의아할 정도로 단순한 원인이었다.</p>

<h2 id="패널-분할-경계-드래그--2분할-기준-계산을-다중-분할로-확장">패널 분할 경계 드래그 — 2분할 기준 계산을 다중 분할로 확장</h2>

<p><strong>증상</strong>: 패널이 3개 이상일 때 경계를 드래그하면 마우스 움직임보다 패널 크기가 덜 따라온다. 2분할일 때는 문제가 없다. 영향 범위는 분할 수가 3~8개인 모든 레이아웃. 엣지 케이스라 실제 사용자가 겪을 확률은 낮았지만, 경계를 자주 조정하는 사용자라면 매번 2분할로 줄였다 늘려야 해서 경험이 나빴다.</p>

<p><strong>단서 수집 + 첫 가설</strong>: 리뷰 중 <code class="language-plaintext highlighter-rouge">pane-size.ts</code> 코드를 읽다가 문득 크기 변화가 이상해 보여서 재현해봤다. 3분할 상태에서 오른쪽 경계를 끌면 내 의도보다 2/3 크기밖에 따라오지 않았다. 처음엔 <strong>마우스 이벤트의 <code class="language-plaintext highlighter-rouge">clientX</code> 계산이 잘못된 줄 알고</strong> <code class="language-plaintext highlighter-rouge">splitPane</code> 함수의 핸들러를 샅샅이 봤다. 하지만 <code class="language-plaintext highlighter-rouge">pointerdown</code>/<code class="language-plaintext highlighter-rouge">pointermove</code>에서 얻는 좌표는 정상이었다. <code class="language-plaintext highlighter-rouge">renderPanes</code>와 <code class="language-plaintext highlighter-rouge">updateSplitLimits</code>의 파라미터도 문제없었다.</p>

<p><strong>원인 추적 — 사고의 진화</strong>: 좌표가 정상이라면 계산 단계에서 뭔가 잘못됐을 거라 생각해 <code class="language-plaintext highlighter-rouge">pane-size.ts</code>의 분할 크기 계산 부분을 집중적으로 봤다. <strong>한 가지 패턴이 눈에 띄었다</strong>: 분할 경계를 움직일 때 새 너비를 결정하는 로직이 <code class="language-plaintext highlighter-rouge">containerWidth / 2</code> 식으로 <strong>항상 2분할 기준의 비율</strong>을 반환하고 있었다. 예를 들어 3분할에서 한 칸의 너비를 늘리면, 나머지 칸은 남은 공간을 분할 수로 나누는데, 이때 <strong>분할 수를 3이 아닌 2로 가정</strong>한 계산이 들어가 있었다. 결과적으로 드래그한 칸은 제대로 늘어나도, 나머지 칸들이 예상보다 적게 줄어들어 마우스와의 차이가 누적됐다.</p>

<blockquote>
  <p><strong>핵심 전환점</strong>: <code class="language-plaintext highlighter-rouge">containerWidth / 2</code>를 발견하고 “아, 분할 개수가 하드코딩됐구나” 싶었다. 2분할일 때만 우연히 맞았고, 3분할부터는 틀어지는 구조였다.</p>
</blockquote>

<p>이 로직을 누가 언제 넣었는지 git log를 뒤져보니, 초기 프로토타입에서 “항상 2분할만 있다”는 가정으로 작성한 코드가 그대로 남아 있었다. 그 뒤 4분할, 8분할로 확장하면서 계산 부분은 고치지 않은 것이다.</p>

<p><strong>해결 — 무엇을 바꿨나</strong>: <code class="language-plaintext highlighter-rouge">pane-size.ts</code>의 분할 크기 계산 함수를 <strong>현재 분할 수를 인자로 받아 동적으로 나누도록</strong> 수정했다. before/after는 아래와 같다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>이전</th>
      <th>이후</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>너비 계산</td>
      <td><code class="language-plaintext highlighter-rouge">containerWidth / 2</code></td>
      <td><code class="language-plaintext highlighter-rouge">containerWidth / paneCount</code></td>
    </tr>
    <tr>
      <td>나머지 분할 재분배</td>
      <td>고정 2분할 기준</td>
      <td><code class="language-plaintext highlighter-rouge">paneCount</code>만큼 균등 분배</td>
    </tr>
    <tr>
      <td>드래그 시 증분 반영</td>
      <td>비율 깨짐</td>
      <td>모든 분할에 균등하게 적용</td>
    </tr>
  </tbody>
</table>

<p>다른 옵션으로는 <strong>각 분할의 절대 너비를 저장하고 비율로 관리</strong>하는 방법도 있었지만, 현재 구조가 상태를 최소로 유지하는 설계라 균등 분배+드래그 증분 방식을 유지했다. 커밋은 브랜치에 반영했다.</p>

<p><strong>검증 + 남은 과제</strong>: <code class="language-plaintext highlighter-rouge">npm test</code>로 기존 473개 테스트 중 472개 통과, 0 실패. <code class="language-plaintext highlighter-rouge">pane-size.test.ts</code>에 3분할·4분할 드래그 시뮬레이션 케이스를 추가했다. 실제 빌드해서 앱을 띄워 3분할 드래그를 반복해봤다 — 이제 마우스와 정확히 같은 비율로 따라온다. 남은 과제는 없고, 이 로직이 다른 레이아웃(세로 분할)에서도 같으 원칙이 적용되는지 확인했으나 모두 <code class="language-plaintext highlighter-rouge">pane-size.ts</code> 하나로 처리되므로 영향 범위는 동일하다.</p>

<h3 id="오늘의-한줄">오늘의 한줄</h3>

<p>인터페이스 계산 로직은 항상 가장 단순한 가정(2분할)에 기반하면 다중 분할에서 깨진다 — “한 군데만 고칠게”를 의심하자.</p>]]></content><author><name>joyuno</name></author><category term="dev-blog" /><category term="dev-blog" /><category term="til" /><summary type="html"><![CDATA[오늘은 Lepons Cockpit에서 패널 분할 경계를 드래그할 때 크기가 마우스보다 덜 따라오는 버그를 잡았다. 처음엔 마우스 이벤트 핸들러 문제라 생각했는데, 파보니 2분할만 고려한 계산 로직이 3분할 이상에서 깨지고 있었다. 리뷰 중 우연히 발견했는데, 고치고 나니 왜 그동안 못 본 건지 의아할 정도로 단순한 원인이었다.]]></summary></entry><entry><title type="html">GCP europe-west2 power event 장애에서 본 존 이슈의 리전 확산 경로와 페일오버 설계</title><link href="https://joyuno.github.io/blog/2026/10/26/gcp-europe-west2-power-event-failover/" rel="alternate" type="text/html" title="GCP europe-west2 power event 장애에서 본 존 이슈의 리전 확산 경로와 페일오버 설계" /><published>2026-10-26T12:52:29+09:00</published><updated>2026-10-26T12:52:29+09:00</updated><id>https://joyuno.github.io/blog/2026/10/26/gcp-europe-west2-power-event-failover</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/26/gcp-europe-west2-power-event-failover/"><![CDATA[<h2 id="타임라인에서-먼저-보이는-것-전원-이벤트는-존에서-끝났지만-영향은-리전으로-퍼졌다">타임라인에서 먼저 보이는 것: 전원 이벤트는 존에서 끝났지만, 영향은 리전으로 퍼졌다</h2>

<p>2026-10-26 KST 시점에서 Google Cloud는 europe-west2(런던) 장애에 대해 상태 페이지에 Preliminary Incident Report를 올렸습니다. 게시 시각은 2026-10-22 01:42 PDT로 보이며, KST로는 2026-10-22 17:42입니다<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<p>상태 페이지가 제공하는 타임라인을 KST까지 포함해 다시 정리하면, 이번 사건은 “단일 존(europe-west2-b)에서 시작한 물리 장애가 리전 단 대규모 장애처럼 관측”되는 전형적인 형태입니다.</p>

<ul>
  <li><strong>2026-10-20 14:53 KST (2026-10-19 22:53 PDT / 2026-10-20 06:53 BST)</strong>: europe-west2-b가 위치한 데이터센터에서 전력망 장애 발생. UPS로 전환 후 디젤 발전기 자동 전환 시퀀스가 동작해야 하는데, PLC(Programmable Logic Controller) 자동화 시스템(주/예비 모두)이 발전기 시작/전환 로직을 시작하지 못했다고 보고됩니다<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li><strong>2026-10-20 15:00 KST (23:00 PDT / 07:00 BST)</strong>: UPS 배터리 고갈 시점부터 물리 호스트 서버, 네트워크 스위칭 패브릭, 로컬 스토리지 어레이가 전원을 잃고 오프라인. 이 시각이 incident start로 잡혀 있습니다<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li><strong>2026-10-20 15:08 KST (23:08 PDT / 07:08 BST)</strong>: 모니터링과 프로버가 API 에러 비율 상승, job submission timeout, europe-west2-b 인프라 도달 불가를 감지해 알림<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li><strong>두 개의 “연속된 phase”</strong>: 고객 영향은 23:00–05:10 PDT(총 6시간 10분) 동안 두 단계로 이어졌고, 초기 phase(23:00–04:29 PDT) 동안 “elevated error rate / API timeout / processing delay”가 광범위하게 관측됐다고 적혀 있습니다<sup id="fnref:1:4" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li><strong>2026-10-20 21:10 KST (05:10 PDT / 13:10 BST)</strong>: “vast majority” 서비스 가용성 회복 및 대부분 고객 영향 해소 시점<sup id="fnref:1:5" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li><strong>2026-10-20 23:03 KST (07:03 PDT / 15:03 BST)</strong>: incident closure notification 게시 시각. 회복 tail을 처리한 뒤 공지를 닫았다고 설명합니다<sup id="fnref:1:6" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
</ul>

<p>언론 보도는 영향을 받은 사용자 입장에서 “런던 지역의 Google Cloud outage가 은행 앱을 흔들었다”는 식으로 관측을 정리했습니다. AP는 6:08 GMT(= 06:08 UTC 근처) 직후 시작, 약 6시간 뒤 대부분 해결로 서술하고, Lloyds/HSBC/Halifax/Revolut 등을 사례로 듭니다<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>.</p>

<p>이 타임라인에서 중요한 포인트는 두 가지입니다.</p>

<p>1) 물리 장애는 “europe-west2-b의 시설 전원”으로 제한되어 있다고 분명히 적혀 있습니다. 그런데도 2) 영향은 단일 존의 VM/DB replica unreachable 수준을 넘어, “regional service control plane의 cross-zone RPC timeout”, “unimpacted zone의 compute slot 포화”, “500/503 증가”로 확장됩니다<sup id="fnref:1:7" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<h2 id="단일-존-이슈가-리전-장애로-보이게-되는-3개의-경로">‘단일 존 이슈’가 리전 장애로 보이게 되는 3개의 경로</h2>

<h3 id="1-데이터플레인의-자동-failover가-만드는-순간적인-수요-쇼크">1) 데이터플레인의 자동 failover가 만드는 순간적인 수요 쇼크</h3>

<p>상태 페이지는 “regional traffic-routing mechanisms와 client applications가 인접 존으로 자동 failover를 시작했다”고 씁니다<sup id="fnref:1:8" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. 여기서 관측이 리전 장애처럼 변형되기 시작합니다.</p>

<ul>
  <li>한 존이 꺼지면, 정상 존이 처리해야 할 트래픽이 늘어납니다.</li>
  <li>평상시에는 존별로 headroom이 있어서 흡수할 수 있지만, 장애 상황에서는 다음이 동시에 발생합니다.
    <ul>
      <li>기존 워크로드의 재스케줄링/재시작</li>
      <li>데이터베이스 HA failover</li>
      <li>큐/스트리밍 처리의 backlog 재처리</li>
      <li>타임아웃을 맞은 클라이언트의 재시도</li>
    </ul>
  </li>
</ul>

<p>상태 페이지가 직접 “redirected query executions, high-availability database failovers, compute workloads”가 다른 존의 capacity를 포화시켜 admission queue 상승, DB failover completion 지연, region 전반의 500/503을 유발했다고 적고 있다는 점이 중요합니다<sup id="fnref:1:9" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<p>즉, 존 하나가 꺼진 것이 1차 충격이라면, 자동 failover로 인해 정상 존에 부하가 몰리며 2차 충격이 만들어집니다. 고객 입장에서는 “나의 리소스는 b가 아니라 a/c에 있는데도 느리다/안 된다”가 됩니다.</p>

<p>멀티존을 했는데도 리전 장애처럼 보이는 가장 흔한 패턴은 “정상 존이 충분히 남아있지만, 남은 용량이 failover 부하를 감당하지 못하는” 상황입니다. 이건 설계가 틀렸다기보다, 멀티존을 <strong>capacity planning</strong>까지 포함한 체계로 보지 않았을 때 생깁니다.</p>

<h3 id="2-제어플레인의-cross-zone-의존성이-존-장애--리전-api-장애를-만든다">2) 제어플레인의 cross-zone 의존성이 ‘존 장애 → 리전 API 장애’를 만든다</h3>

<p>이번 사건에서 제일 날카로운 문장은 이것입니다.</p>

<ul>
  <li>“Cross-zone remote procedure calls from regional service control planes directed to metadata and catalog components in the offline zone encountered timeouts, causing initial job submission stalls and API latency spikes.”<sup id="fnref:1:10" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>여기서 리전 장애처럼 보이는 이유가 설명됩니다.</p>

<ul>
  <li>데이터플레인(내 워커/내 DB replica)은 살아 있어도,</li>
  <li>컨트롤러(스케줄러/프로비저너/오케스트레이터)가 ‘어딘가의 metadata/catalog’를 조회하거나 검증해야 해서 API를 처리합니다.</li>
  <li>그 metadata/catalog가 오프라인 존에 있고, 컨트롤러가 거기에 cross-zone RPC를 날리다 timeout을 맞으면,</li>
  <li>결과적으로 “새 job이 안 올라간다”, “scale out이 안 된다”, “API가 느리거나 timeout”이 됩니다.</li>
</ul>

<p>이런 현상은 고객이 멀티존으로 VM을 분산해뒀는지와 무관하게 나타날 수 있습니다. 컨트롤플레인이 ‘논리적으로는 regional’이지만 ‘물리적으로는 특정 존의 컴포넌트에 의존’하는 순간, 단일 존 outage가 제어플레인 장애로 확대됩니다.</p>

<p>상태 페이지의 “Affected products included … unexpected degradation and failover issues … control-plane API timeouts waiting on validations from the offline zone”라는 문장도 같은 결을 강화합니다<sup id="fnref:1:11" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<h3 id="3-리트라이-증폭retry-amplification이-복구-시간을-늘리고-정상-존을-더-느리게-만든다">3) 리트라이 증폭(retry amplification)이 복구 시간을 늘리고, 정상 존을 더 느리게 만든다</h3>

<p>장애가 나면 개발자는 “재시도하면 되겠지”를 떠올립니다. Google도 SRE 관점에서 “모든 RPC 클라이언트는 jitter가 포함된 exponential backoff를 구현해야 한다”고 못 박습니다<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>.</p>

<p>문제는, 많은 시스템이 다음의 형태로 재시도를 ‘겹겹이’ 가지고 있다는 점입니다.</p>

<ul>
  <li>사용자 앱(모바일/웹)이 재시도</li>
  <li>API Gateway/Service Mesh가 재시도</li>
  <li>SDK(Cloud Client Libraries 등)가 재시도</li>
  <li>내부 fan-out 호출(마이크로서비스)이 재시도</li>
</ul>

<p>이때 500/503/timeouts가 발생하면, 총 요청 수가 원래 트래픽보다 더 커질 수 있습니다. Google SRE 책은 이를 cascading failure의 전형적 패턴으로 설명하고 randomized exponential backoff를 강조합니다<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>.</p>

<p>Google Cloud 문서에서도 API 재시도 시 “truncated exponential backoff with introduced jitter”를 권고하면서, 동기화된 재시도(thundering herd)와 quota 초과 위험을 직접 언급합니다<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>.</p>

<p>이번 europe-west2 사건에 그대로 대응시키면 다음의 루프가 만들어집니다.</p>

<p>1) europe-west2-b 오프라인 → 일부 요청 timeout/실패
2) 트래픽 라우팅/클라이언트 failover → a/c로 몰림
3) a/c에서 admission queue 상승 → 500/503 증가
4) 클라이언트/게이트웨이/SDK 재시도 → 더 몰림
5) 정상 존도 느려지고 에러가 늘어남 → 리전 장애처럼 보임</p>

<p>이 루프를 끊는 설계가 “페일오버가 실제로 동작한다”의 핵심입니다.</p>

<h2 id="멀티존이-약해지는-지점-공유-의존성shared-dependency을-못-본다">멀티존이 약해지는 지점: 공유 의존성(shared dependency)을 못 본다</h2>

<p>멀티존 설계는 흔히 “워크로드를 3개 zone에 나눠 깐다”로 끝납니다. 그런데 이번 사건은 그 다음 레이어가 더 중요하다는 것을 보여줍니다.</p>

<p>상태 페이지에 명시된 확산 경로를 다시 보면, 공유 의존성은 크게 두 부류였습니다.</p>

<ul>
  <li>제어플레인/메타 계층: regional service control plane이 metadata/catalog 컴포넌트를 조회하는 경로가 오프라인 존으로 향했고 timeout을 유발<sup id="fnref:1:12" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
  <li>용량/스케줄링 계층: redirected workload가 정상 존의 compute slot/processing capacity를 포화시켜 admission queue 및 API 500/503을 만들었음<sup id="fnref:1:13" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>이 모델을 내 시스템에 적용해보면 체크해야 할 질문이 바뀝니다.</p>

<ul>
  <li>“내 서비스는 multi-zone인가?”가 아니라</li>
  <li>“내가 기대하는 failover 시나리오에서 control plane이 어떤 검증/메타데이터를 어디서 읽는가?”</li>
  <li>“failover 순간에 남은 zone이 감당해야 하는 peak load는 얼마인가? failover storm까지 포함하면 얼마인가?”</li>
</ul>

<p>이번 장애의 영향 서비스 목록에 Cloud Load Balancing, Cloud NAT, VPC 같은 네트워킹 계층까지 들어가 있다는 점도 눈여겨볼 만합니다<sup id="fnref:1:14" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. 애플리케이션이 멀티존이라도, 네트워크 제어/데이터 경로가 같은 실패 도메인을 공유하면 관측상 “다 죽은 것처럼” 보일 수 있습니다.</p>

<h2 id="멀티리전-전환을-실제로-동작시키는-구성-cloud-run--global-external-application-load-balancer">멀티리전 전환을 실제로 동작시키는 구성: Cloud Run + Global external Application Load Balancer</h2>

<p>멀티존을 넘어서 멀티리전으로 가면 “한 리전의 제어플레인/용량 문제”에서 빠져나올 가능성이 커집니다. Cloud Run은 서비스가 리전 단위로 배포되기 때문에, 문서도 “여러 리전에 배포하고 global load balancing을 구성해야 한다”고 정리합니다<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>.</p>

<p>여기서 중요한 건, 멀티리전이 ‘설정값’이 아니라 ‘동작하는 시스템’이 되려면 다음 두 가지를 만족해야 한다는 점입니다.</p>

<ul>
  <li>트래픽 전환이 자동으로 일어나고(또는 수동 전환이 충분히 빠르고),</li>
  <li>전환 순간에 재시도/백로그/콜드스타트가 정상 리전을 압살하지 않는다.</li>
</ul>

<p>Cloud Run에는 이를 위해 “service health”라는 메커니즘을 제공합니다. readiness probe를 바탕으로 리전별 서비스 건강도를 집계하고, global external Application Load Balancer(또는 cross-region internal Application Load Balancer)가 unhealthy 리전을 자동으로 빼도록 설계합니다<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup>.</p>

<p>아래는 내가 실제로 운영에 가져갈 때 최소한으로 확인하는 형태의 예시입니다. 장난감 수준을 피하기 위해 “의존 서비스(catalog)가 죽으면 API를 즉시 unready로 내려서 트래픽을 다른 리전으로 보내는” 형태로 구성합니다.</p>

<h3 id="예시-시나리오">예시 시나리오</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">catalog</code> 서비스: 단순 조회 API(다운스트림 역할)</li>
  <li><code class="language-plaintext highlighter-rouge">checkout</code> 서비스: 요청마다 <code class="language-plaintext highlighter-rouge">catalog</code>를 호출해야만 정상 응답 가능</li>
  <li>멀티리전: europe-west2(런던), europe-west1(벨기에)</li>
  <li>라우팅: Global external Application Load Balancer + Cloud Run service health</li>
  <li>장애 가정: 런던 리전의 <code class="language-plaintext highlighter-rouge">catalog</code> 또는 네트워크가 불안정해지면 <code class="language-plaintext highlighter-rouge">checkout</code> readiness가 즉시 실패 → 런던 리전 <code class="language-plaintext highlighter-rouge">checkout</code>을 unhealthy로 판단 → 로드밸런서가 벨기에로 우회</li>
</ul>

<p>Cloud Run service health는 “컨테이너 readiness probe 결과를 Cloud Run이 집계해 리전 단위 health로 노출한다”고 설명합니다<sup id="fnref:7:1" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup>.</p>

<h3 id="애플리케이션-코드-go-123-기준">애플리케이션 코드 (Go 1.23 기준)</h3>

<p><code class="language-plaintext highlighter-rouge">MODE=catalog</code> 또는 <code class="language-plaintext highlighter-rouge">MODE=checkout</code>으로 같은 이미지가 두 역할을 수행하도록 만들면 배포가 단순해집니다.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// go.mod</span>
<span class="n">module</span> <span class="n">example</span><span class="o">.</span><span class="n">com</span><span class="o">/</span><span class="n">multiregion</span><span class="o">-</span><span class="n">run</span>

<span class="k">go</span> <span class="m">1.23</span>
</code></pre></div></div>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// main.go</span>
<span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="n">context</span> <span class="s">"context"</span>
	<span class="n">cryptoRand</span> <span class="s">"crypto/rand"</span>
	<span class="n">encodingBinary</span> <span class="s">"encoding/binary"</span>
	<span class="n">fmt</span> <span class="s">"fmt"</span>
	<span class="n">io</span> <span class="s">"io"</span>
	<span class="n">log</span> <span class="s">"log"</span>
	<span class="n">net</span> <span class="s">"net"</span>
	<span class="n">nethttp</span> <span class="s">"net/http"</span>
	<span class="n">os</span> <span class="s">"os"</span>
	<span class="n">time</span> <span class="s">"time"</span>
<span class="p">)</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Mode</span>              <span class="kt">string</span>
	<span class="n">Port</span>              <span class="kt">string</span>
	<span class="n">CatalogURL</span>        <span class="kt">string</span>
	<span class="n">ReadyTimeout</span>      <span class="n">time</span><span class="o">.</span><span class="n">Duration</span>
	<span class="n">CallTimeout</span>       <span class="n">time</span><span class="o">.</span><span class="n">Duration</span>
	<span class="n">MaxAttempts</span>       <span class="kt">int</span>
	<span class="n">BackoffBase</span>       <span class="n">time</span><span class="o">.</span><span class="n">Duration</span>
	<span class="n">BackoffCap</span>        <span class="n">time</span><span class="o">.</span><span class="n">Duration</span>
	<span class="n">OverallDeadline</span>   <span class="n">time</span><span class="o">.</span><span class="n">Duration</span>
	<span class="n">MaxConnsPerHost</span>   <span class="kt">int</span>
	<span class="n">IdleConnTimeout</span>   <span class="n">time</span><span class="o">.</span><span class="n">Duration</span>
	<span class="n">DisableKeepAlives</span> <span class="kt">bool</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">env</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">def</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
	<span class="n">v</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">v</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">def</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="n">v</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">envDuration</span><span class="p">(</span><span class="n">key</span> <span class="kt">string</span><span class="p">,</span> <span class="n">def</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">)</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span> <span class="p">{</span>
	<span class="n">v</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">v</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">def</span>
	<span class="p">}</span>
	<span class="n">d</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">ParseDuration</span><span class="p">(</span><span class="n">v</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">def</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="n">d</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">envInt</span><span class="p">(</span><span class="n">key</span> <span class="kt">string</span><span class="p">,</span> <span class="n">def</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
	<span class="n">v</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">v</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">def</span>
	<span class="p">}</span>
	<span class="k">var</span> <span class="n">n</span> <span class="kt">int</span>
	<span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sscanf</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="s">"%d"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">n</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">def</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="n">n</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">loadConfig</span><span class="p">()</span> <span class="n">Config</span> <span class="p">{</span>
	<span class="k">return</span> <span class="n">Config</span><span class="p">{</span>
		<span class="n">Mode</span><span class="o">:</span>              <span class="n">env</span><span class="p">(</span><span class="s">"MODE"</span><span class="p">,</span> <span class="s">"checkout"</span><span class="p">),</span>
		<span class="n">Port</span><span class="o">:</span>              <span class="n">env</span><span class="p">(</span><span class="s">"PORT"</span><span class="p">,</span> <span class="s">"8080"</span><span class="p">),</span>
		<span class="n">CatalogURL</span><span class="o">:</span>        <span class="n">env</span><span class="p">(</span><span class="s">"CATALOG_URL"</span><span class="p">,</span> <span class="s">""</span><span class="p">),</span>
		<span class="n">ReadyTimeout</span><span class="o">:</span>      <span class="n">envDuration</span><span class="p">(</span><span class="s">"READY_TIMEOUT"</span><span class="p">,</span> <span class="m">250</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">),</span>
		<span class="n">CallTimeout</span><span class="o">:</span>       <span class="n">envDuration</span><span class="p">(</span><span class="s">"CALL_TIMEOUT"</span><span class="p">,</span> <span class="m">400</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">),</span>
		<span class="n">MaxAttempts</span><span class="o">:</span>       <span class="n">envInt</span><span class="p">(</span><span class="s">"MAX_ATTEMPTS"</span><span class="p">,</span> <span class="m">3</span><span class="p">),</span>
		<span class="n">BackoffBase</span><span class="o">:</span>       <span class="n">envDuration</span><span class="p">(</span><span class="s">"BACKOFF_BASE"</span><span class="p">,</span> <span class="m">50</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">),</span>
		<span class="n">BackoffCap</span><span class="o">:</span>        <span class="n">envDuration</span><span class="p">(</span><span class="s">"BACKOFF_CAP"</span><span class="p">,</span> <span class="m">500</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">),</span>
		<span class="n">OverallDeadline</span><span class="o">:</span>   <span class="n">envDuration</span><span class="p">(</span><span class="s">"OVERALL_DEADLINE"</span><span class="p">,</span> <span class="m">900</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">),</span>
		<span class="n">MaxConnsPerHost</span><span class="o">:</span>   <span class="n">envInt</span><span class="p">(</span><span class="s">"MAX_CONNS_PER_HOST"</span><span class="p">,</span> <span class="m">64</span><span class="p">),</span>
		<span class="n">IdleConnTimeout</span><span class="o">:</span>   <span class="n">envDuration</span><span class="p">(</span><span class="s">"IDLE_CONN_TIMEOUT"</span><span class="p">,</span> <span class="m">30</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">),</span>
		<span class="n">DisableKeepAlives</span><span class="o">:</span> <span class="n">env</span><span class="p">(</span><span class="s">"DISABLE_KEEPALIVES"</span><span class="p">,</span> <span class="s">"false"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"true"</span><span class="p">,</span>
	<span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">newHTTPClient</span><span class="p">(</span><span class="n">cfg</span> <span class="n">Config</span><span class="p">)</span> <span class="o">*</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Client</span> <span class="p">{</span>
	<span class="n">tr</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Transport</span><span class="p">{</span>
		<span class="n">DialContext</span><span class="o">:</span> <span class="p">(</span><span class="o">&amp;</span><span class="n">net</span><span class="o">.</span><span class="n">Dialer</span><span class="p">{</span><span class="n">Timeout</span><span class="o">:</span> <span class="m">300</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">})</span><span class="o">.</span><span class="n">DialContext</span><span class="p">,</span>
		<span class="n">MaxConnsPerHost</span><span class="o">:</span>     <span class="n">cfg</span><span class="o">.</span><span class="n">MaxConnsPerHost</span><span class="p">,</span>
		<span class="n">MaxIdleConns</span><span class="o">:</span>        <span class="m">256</span><span class="p">,</span>
		<span class="n">MaxIdleConnsPerHost</span><span class="o">:</span> <span class="n">cfg</span><span class="o">.</span><span class="n">MaxConnsPerHost</span><span class="p">,</span>
		<span class="n">IdleConnTimeout</span><span class="o">:</span>     <span class="n">cfg</span><span class="o">.</span><span class="n">IdleConnTimeout</span><span class="p">,</span>
		<span class="n">DisableKeepAlives</span><span class="o">:</span>   <span class="n">cfg</span><span class="o">.</span><span class="n">DisableKeepAlives</span><span class="p">,</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="o">&amp;</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Client</span><span class="p">{</span><span class="n">Transport</span><span class="o">:</span> <span class="n">tr</span><span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">randFloat01</span><span class="p">()</span> <span class="kt">float64</span> <span class="p">{</span>
	<span class="k">var</span> <span class="n">b</span> <span class="p">[</span><span class="m">8</span><span class="p">]</span><span class="kt">byte</span>
	<span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">cryptoRand</span><span class="o">.</span><span class="n">Read</span><span class="p">(</span><span class="n">b</span><span class="p">[</span><span class="o">:</span><span class="p">])</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="c">// 최악의 경우 동기화 위험이 커지므로, 실패하면 시간을 섞어서라도 분산</span>
		<span class="k">return</span> <span class="kt">float64</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span><span class="o">.</span><span class="n">UnixNano</span><span class="p">()</span><span class="o">%</span><span class="m">1</span><span class="n">_000_000</span><span class="p">)</span> <span class="o">/</span> <span class="m">1</span><span class="n">_000_000</span>
	<span class="p">}</span>
	<span class="n">u</span> <span class="o">:=</span> <span class="n">binary</span><span class="o">.</span><span class="n">BigEndian</span><span class="o">.</span><span class="n">Uint64</span><span class="p">(</span><span class="n">b</span><span class="p">[</span><span class="o">:</span><span class="p">])</span>
	<span class="k">return</span> <span class="kt">float64</span><span class="p">(</span><span class="n">u</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="m">1</span><span class="o">&lt;&lt;</span><span class="m">64</span> <span class="o">-</span> <span class="m">1</span><span class="p">)</span>
<span class="p">}</span>

<span class="c">// full jitter: sleep = random_between(0, min(cap, base*2^attempt))</span>
<span class="k">func</span> <span class="n">sleepWithJitter</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="nb">cap</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">,</span> <span class="n">attempt</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">exp</span> <span class="o">:=</span> <span class="n">base</span> <span class="o">*</span> <span class="p">(</span><span class="m">1</span> <span class="o">&lt;&lt;</span> <span class="n">attempt</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">exp</span> <span class="o">&gt;</span> <span class="nb">cap</span> <span class="p">{</span>
		<span class="n">exp</span> <span class="o">=</span> <span class="nb">cap</span>
	<span class="p">}</span>
	<span class="n">d</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">(</span><span class="n">randFloat01</span><span class="p">()</span> <span class="o">*</span> <span class="kt">float64</span><span class="p">(</span><span class="n">exp</span><span class="p">))</span>
	<span class="n">t</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">NewTimer</span><span class="p">(</span><span class="n">d</span><span class="p">)</span>
	<span class="k">defer</span> <span class="n">t</span><span class="o">.</span><span class="n">Stop</span><span class="p">()</span>
	<span class="k">select</span> <span class="p">{</span>
	<span class="k">case</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
		<span class="k">return</span> <span class="n">ctx</span><span class="o">.</span><span class="n">Err</span><span class="p">()</span>
	<span class="k">case</span> <span class="o">&lt;-</span><span class="n">t</span><span class="o">.</span><span class="n">C</span><span class="o">:</span>
		<span class="k">return</span> <span class="no">nil</span>
	<span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">getWithRetry</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">client</span> <span class="o">*</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Client</span><span class="p">,</span> <span class="n">url</span> <span class="kt">string</span><span class="p">,</span> <span class="n">cfg</span> <span class="n">Config</span><span class="p">)</span> <span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">OverallDeadline</span><span class="p">)</span>
	<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

	<span class="k">var</span> <span class="n">lastErr</span> <span class="kt">error</span>
	<span class="k">for</span> <span class="n">attempt</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">attempt</span> <span class="o">&lt;</span> <span class="n">cfg</span><span class="o">.</span><span class="n">MaxAttempts</span><span class="p">;</span> <span class="n">attempt</span><span class="o">++</span> <span class="p">{</span>
		<span class="n">reqCtx</span><span class="p">,</span> <span class="n">reqCancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">CallTimeout</span><span class="p">)</span>
		<span class="n">req</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">NewRequestWithContext</span><span class="p">(</span><span class="n">reqCtx</span><span class="p">,</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">MethodGet</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="no">nil</span><span class="p">)</span>

		<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">client</span><span class="o">.</span><span class="n">Do</span><span class="p">(</span><span class="n">req</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">body</span><span class="p">,</span> <span class="n">readErr</span> <span class="o">:=</span> <span class="n">io</span><span class="o">.</span><span class="n">ReadAll</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="p">)</span>
			<span class="n">_</span> <span class="o">=</span> <span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>
			<span class="k">if</span> <span class="n">readErr</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
				<span class="n">err</span> <span class="o">=</span> <span class="n">readErr</span>
			<span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span> <span class="o">&gt;=</span> <span class="m">200</span> <span class="o">&amp;&amp;</span> <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span> <span class="o">&lt;</span> <span class="m">300</span> <span class="p">{</span>
				<span class="k">return</span> <span class="n">body</span><span class="p">,</span> <span class="no">nil</span>
			<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
				<span class="n">err</span> <span class="o">=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"upstream status=%d body=%s"</span><span class="p">,</span> <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span><span class="p">,</span> <span class="kt">string</span><span class="p">(</span><span class="n">body</span><span class="p">))</span>
			<span class="p">}</span>
		<span class="p">}</span>
		<span class="n">reqCancel</span><span class="p">()</span>
		<span class="n">lastErr</span> <span class="o">=</span> <span class="n">err</span>

		<span class="c">// 마지막 시도면 더 자지 않음</span>
		<span class="k">if</span> <span class="n">attempt</span> <span class="o">==</span> <span class="n">cfg</span><span class="o">.</span><span class="n">MaxAttempts</span><span class="o">-</span><span class="m">1</span> <span class="p">{</span>
			<span class="k">break</span>
		<span class="p">}</span>
		<span class="n">_</span> <span class="o">=</span> <span class="n">sleepWithJitter</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">BackoffBase</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">BackoffCap</span><span class="p">,</span> <span class="n">attempt</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">lastErr</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">cfg</span> <span class="o">:=</span> <span class="n">loadConfig</span><span class="p">()</span>
	<span class="n">client</span> <span class="o">:=</span> <span class="n">newHTTPClient</span><span class="p">(</span><span class="n">cfg</span><span class="p">)</span>

	<span class="n">mux</span> <span class="o">:=</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">NewServeMux</span><span class="p">()</span>

	<span class="n">mux</span><span class="o">.</span><span class="n">HandleFunc</span><span class="p">(</span><span class="s">"/healthz"</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">w</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">ResponseWriter</span><span class="p">,</span> <span class="n">r</span> <span class="o">*</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="p">{</span>
		<span class="c">// liveness: 프로세스가 살아있는지</span>
		<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">200</span><span class="p">)</span>
		<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"ok"</span><span class="p">))</span>
	<span class="p">})</span>

	<span class="n">mux</span><span class="o">.</span><span class="n">HandleFunc</span><span class="p">(</span><span class="s">"/readyz"</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">w</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">ResponseWriter</span><span class="p">,</span> <span class="n">r</span> <span class="o">*</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Mode</span> <span class="o">==</span> <span class="s">"catalog"</span> <span class="p">{</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">200</span><span class="p">)</span>
			<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"ready"</span><span class="p">))</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="c">// checkout은 catalog에 의존. catalog 호출이 불안정하면 ready를 내림.</span>
		<span class="k">if</span> <span class="n">cfg</span><span class="o">.</span><span class="n">CatalogURL</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">500</span><span class="p">)</span>
			<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"CATALOG_URL required"</span><span class="p">))</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">Context</span><span class="p">(),</span> <span class="n">cfg</span><span class="o">.</span><span class="n">ReadyTimeout</span><span class="p">)</span>
		<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

		<span class="n">url</span> <span class="o">:=</span> <span class="n">cfg</span><span class="o">.</span><span class="n">CatalogURL</span> <span class="o">+</span> <span class="s">"/catalog/ping"</span>
		<span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">getWithRetry</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">client</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">Config</span><span class="p">{</span>
			<span class="n">CallTimeout</span><span class="o">:</span>     <span class="m">150</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">,</span>
			<span class="n">OverallDeadline</span><span class="o">:</span> <span class="n">cfg</span><span class="o">.</span><span class="n">ReadyTimeout</span><span class="p">,</span>
			<span class="n">MaxAttempts</span><span class="o">:</span>     <span class="m">1</span><span class="p">,</span> <span class="c">// readiness에 재시도를 넣으면 전환이 늦어짐</span>
			<span class="n">BackoffBase</span><span class="o">:</span>     <span class="m">10</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">,</span>
			<span class="n">BackoffCap</span><span class="o">:</span>      <span class="m">10</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">,</span>
		<span class="p">})</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">503</span><span class="p">)</span>
			<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"not ready: "</span> <span class="o">+</span> <span class="n">err</span><span class="o">.</span><span class="n">Error</span><span class="p">()))</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">200</span><span class="p">)</span>
		<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"ready"</span><span class="p">))</span>
	<span class="p">})</span>

	<span class="n">mux</span><span class="o">.</span><span class="n">HandleFunc</span><span class="p">(</span><span class="s">"/catalog/ping"</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">w</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">ResponseWriter</span><span class="p">,</span> <span class="n">r</span> <span class="o">*</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Mode</span> <span class="o">!=</span> <span class="s">"catalog"</span> <span class="p">{</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">404</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">200</span><span class="p">)</span>
		<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"pong"</span><span class="p">))</span>
	<span class="p">})</span>

	<span class="n">mux</span><span class="o">.</span><span class="n">HandleFunc</span><span class="p">(</span><span class="s">"/checkout"</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">w</span> <span class="n">nethttp</span><span class="o">.</span><span class="n">ResponseWriter</span><span class="p">,</span> <span class="n">r</span> <span class="o">*</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Mode</span> <span class="o">!=</span> <span class="s">"checkout"</span> <span class="p">{</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">404</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="k">if</span> <span class="n">cfg</span><span class="o">.</span><span class="n">CatalogURL</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">500</span><span class="p">)</span>
			<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"CATALOG_URL required"</span><span class="p">))</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="n">start</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span>
		<span class="n">body</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">getWithRetry</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">Context</span><span class="p">(),</span> <span class="n">client</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">CatalogURL</span><span class="o">+</span><span class="s">"/catalog/ping"</span><span class="p">,</span> <span class="n">cfg</span><span class="p">)</span>
		<span class="n">dur</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Since</span><span class="p">(</span><span class="n">start</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="c">// 중요한 점: 여기서 무한 재시도, 장시간 timeout은 금물</span>
			<span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"catalog error: %v dur=%s"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">dur</span><span class="p">)</span>
			<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">503</span><span class="p">)</span>
			<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"upstream unavailable"</span><span class="p">))</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="m">200</span><span class="p">)</span>
		<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"checkout ok, catalog=%s dur=%s"</span><span class="p">,</span> <span class="kt">string</span><span class="p">(</span><span class="n">body</span><span class="p">),</span> <span class="n">dur</span><span class="p">)))</span>
	<span class="p">})</span>

	<span class="n">srv</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">nethttp</span><span class="o">.</span><span class="n">Server</span><span class="p">{</span>
		<span class="n">Addr</span><span class="o">:</span>              <span class="s">":"</span> <span class="o">+</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Port</span><span class="p">,</span>
		<span class="n">Handler</span><span class="o">:</span>           <span class="n">mux</span><span class="p">,</span>
		<span class="n">ReadHeaderTimeout</span><span class="o">:</span> <span class="m">2</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">,</span>
	<span class="p">}</span>

	<span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"starting mode=%s port=%s"</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Mode</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Port</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">srv</span><span class="o">.</span><span class="n">ListenAndServe</span><span class="p">();</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 코드에서 운영적으로 중요한 의도가 몇 가지 있습니다.</p>

<ul>
  <li>readiness(<code class="language-plaintext highlighter-rouge">/readyz</code>)에는 재시도를 거의 넣지 않았습니다. readiness가 늦게 실패하면 로드밸런서가 늦게 우회합니다.</li>
  <li>실제 호출(<code class="language-plaintext highlighter-rouge">/checkout</code>)에는 “짧은 deadline + 작은 횟수의 재시도 + jitter”를 넣었습니다. Google SRE가 강조하는 retry amplification 완화 원칙과 같은 방향입니다<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>.</li>
  <li><code class="language-plaintext highlighter-rouge">MaxConnsPerHost</code>로 연결 수를 어느 정도 제한했습니다. 장애 상황에서 unbounded concurrency는 스스로를 DDoS하는 형태가 되기 쉽습니다.</li>
</ul>

<h3 id="dockerfile">Dockerfile</h3>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># syntax=docker/dockerfile:1</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.23</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="s">build</span>
<span class="k">WORKDIR</span><span class="s"> /src</span>
<span class="k">COPY</span><span class="s"> go.mod ./</span>
<span class="k">COPY</span><span class="s"> main.go ./</span>
<span class="k">RUN </span><span class="nv">CGO_ENABLED</span><span class="o">=</span>0 <span class="nv">GOOS</span><span class="o">=</span>linux <span class="nv">GOARCH</span><span class="o">=</span>amd64 go build <span class="nt">-o</span> /out/app ./

<span class="k">FROM</span><span class="s"> gcr.io/distroless/static-debian12:nonroot</span>
<span class="k">COPY</span><span class="s"> --from=build /out/app /app</span>
<span class="k">USER</span><span class="s"> nonroot:nonroot</span>
<span class="k">ENV</span><span class="s"> PORT=8080</span>
<span class="k">EXPOSE</span><span class="s"> 8080</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/app"]</span>
</code></pre></div></div>

<h3 id="배포-gcloud-cli">배포 (gcloud CLI)</h3>

<p>Cloud Run은 리전 단위 배포이므로 두 리전에 각각 배포합니다. 멀티리전 서비스 구성 자체는 문서가 안내하는 형태를 따라갑니다<sup id="fnref:6:1" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PROJECT_ID</span><span class="o">=</span><span class="s2">"YOUR_PROJECT"</span>
<span class="nv">IMAGE</span><span class="o">=</span><span class="s2">"europe-docker.pkg.dev/</span><span class="k">${</span><span class="nv">PROJECT_ID</span><span class="k">}</span><span class="s2">/demo/multiregion-run:1"</span>

<span class="c"># Artifact Registry</span>
gcloud artifacts repositories create demo <span class="se">\</span>
  <span class="nt">--repository-format</span><span class="o">=</span>docker <span class="se">\</span>
  <span class="nt">--location</span><span class="o">=</span>europe <span class="se">\</span>
  <span class="nt">--project</span> <span class="s2">"</span><span class="nv">$PROJECT_ID</span><span class="s2">"</span>

gcloud auth configure-docker europe-docker.pkg.dev

docker build <span class="nt">-t</span> <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="nb">.</span>
docker push <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span>

<span class="c"># catalog in europe-west2</span>
gcloud run deploy catalog <span class="se">\</span>
  <span class="nt">--image</span> <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--region</span> europe-west2 <span class="se">\</span>
  <span class="nt">--set-env-vars</span> <span class="nv">MODE</span><span class="o">=</span>catalog <span class="se">\</span>
  <span class="nt">--no-allow-unauthenticated</span>

<span class="c"># catalog in europe-west1</span>
gcloud run deploy catalog <span class="se">\</span>
  <span class="nt">--image</span> <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--region</span> europe-west1 <span class="se">\</span>
  <span class="nt">--set-env-vars</span> <span class="nv">MODE</span><span class="o">=</span>catalog <span class="se">\</span>
  <span class="nt">--no-allow-unauthenticated</span>
</code></pre></div></div>

<p>이어서 <code class="language-plaintext highlighter-rouge">checkout</code>을 배포합니다. 여기서 <code class="language-plaintext highlighter-rouge">CATALOG_URL</code>은 같은 리전의 catalog를 보도록 설정합니다(리전 내부 의존성을 분리해야, 한 리전이 죽을 때 다른 리전을 끌고 들어가지 않습니다).</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">CATALOG_W2_URL</span><span class="o">=</span><span class="s2">"https://catalog-&lt;hash&gt;-ew2.a.run.app"</span>
<span class="nv">CATALOG_W1_URL</span><span class="o">=</span><span class="s2">"https://catalog-&lt;hash&gt;-ew1.a.run.app"</span>

<span class="c"># checkout in europe-west2</span>
gcloud run deploy checkout <span class="se">\</span>
  <span class="nt">--image</span> <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--region</span> europe-west2 <span class="se">\</span>
  <span class="nt">--set-env-vars</span> <span class="nv">MODE</span><span class="o">=</span>checkout,CATALOG_URL<span class="o">=</span><span class="s2">"</span><span class="nv">$CATALOG_W2_URL</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--no-allow-unauthenticated</span> <span class="se">\</span>
  <span class="nt">--set-secrets</span><span class="o">=</span><span class="s2">""</span> <span class="se">\</span>
  <span class="nt">--update-env-vars</span> <span class="nv">READY_TIMEOUT</span><span class="o">=</span>250ms,CALL_TIMEOUT<span class="o">=</span>400ms,OVERALL_DEADLINE<span class="o">=</span>900ms,MAX_ATTEMPTS<span class="o">=</span>3

<span class="c"># checkout in europe-west1</span>
gcloud run deploy checkout <span class="se">\</span>
  <span class="nt">--image</span> <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--region</span> europe-west1 <span class="se">\</span>
  <span class="nt">--set-env-vars</span> <span class="nv">MODE</span><span class="o">=</span>checkout,CATALOG_URL<span class="o">=</span><span class="s2">"</span><span class="nv">$CATALOG_W1_URL</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--no-allow-unauthenticated</span> <span class="se">\</span>
  <span class="nt">--update-env-vars</span> <span class="nv">READY_TIMEOUT</span><span class="o">=</span>250ms,CALL_TIMEOUT<span class="o">=</span>400ms,OVERALL_DEADLINE<span class="o">=</span>900ms,MAX_ATTEMPTS<span class="o">=</span>3
</code></pre></div></div>

<p>예상 출력(요지)은 다음과 비슷합니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Deploying container to Cloud Run service [checkout] in project [YOUR_PROJECT] region [europe-west2]
...done
Service [checkout] revision [checkout-00001-...] has been deployed
</code></pre></div></div>

<h3 id="트래픽-라우팅-global-external-application-load-balancer--service-health">트래픽 라우팅: Global external Application Load Balancer + service health</h3>

<p>Cloud Run 멀티리전 라우팅은 “리전별 serverless NEG를 만들고 global load balancing을 설정”하는 형태입니다<sup id="fnref:6:2" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>. 그리고 자동 failover/failback을 원하면 service health 가이드를 보라고 안내합니다<sup id="fnref:6:3" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>.</p>

<p>로드밸런서 동작 자체는 “Premium tier에서 가장 가까운 healthy backend로 라우팅하고 edge에서 최대한 가까이 termination한다”가 핵심입니다<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup>.</p>

<p>여기서 운영자가 반드시 확인할 체크포인트는 다음입니다.</p>

<ul>
  <li>로드밸런서가 “헬스”를 무엇으로 판단하는가: 단순 200이 아니라, 내 서비스의 dependency까지 포함한 readiness가 반영되는가.</li>
  <li>unhealthy 판단이 늦으면, 장애 리전에 트래픽이 계속 들어가서 리트라이가 폭발한다.</li>
  <li>healthy로 복귀할 때 “failback”이 너무 공격적이면, 복구 직후 리전에 트래픽이 몰려 2차 장애를 만든다(이번 incident에서도 controlled traffic re-admission을 강조합니다<sup id="fnref:1:15" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>).</li>
</ul>

<p>Cloud Run 쪽 문서는 “readiness probe를 집계해 리전별 서비스 건강도를 노출한다”고 설명합니다<sup id="fnref:7:2" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup>. 이건 곧, readiness 설계를 대충하면 멀티리전 자체가 종이 위 설계가 되기 쉽다는 뜻입니다.</p>

<h2 id="장애가-연쇄로-보일-때의-방어선-멀티존멀티리전-체크리스트">장애가 ‘연쇄’로 보일 때의 방어선: 멀티존/멀티리전 체크리스트</h2>

<p>이번 europe-west2 사건에서 드러난 확산 경로(제어플레인 cross-zone timeout, 정상 존 용량 포화, 500/503 증가)를 기준으로 체크리스트를 만들면, 질문이 인프라/앱/클라이언트까지 내려옵니다.</p>

<h3 id="트래픽-전환-계층-전환-장치가-여러-개면-우선순위를-정해야-한다">트래픽 전환 계층: 전환 장치가 여러 개면, 우선순위를 정해야 한다</h3>

<ul>
  <li>Global external Application Load Balancer 같은 멀티리전 장치가 있으면, DNS 기반 전환(예: TTL 짧게)과 역할이 겹칠 수 있습니다.</li>
  <li>Cloud DNS는 routing policy(WRR, GEO, failover)와 health check를 제공하지만, DNS는 캐시/TTL 특성상 “정확히 즉시” 바뀌지 않는 것을 문서가 직접 경고합니다<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup>.</li>
</ul>

<p>내 경험상, “전환 장치가 많을수록 안전”이 아니라 “전환 장치가 많을수록 실제 장애 시 어떤 게 동작했는지 모른다”가 됩니다. 멀티리전은 가능하면 L7 전환(로드밸런서)을 1차로 두고, DNS는 최후의 백업(또는 온프레/타 클라우드 연동)처럼 성격을 나누는 편이 운영이 단단해집니다.</p>

<h3 id="제어플레인오케스트레이션-계층-새로-만들기create가-막히면-이미-떠-있는-것만으로-버텨야-한다">제어플레인/오케스트레이션 계층: 새로 만들기(Create)가 막히면 이미 떠 있는 것만으로 버텨야 한다</h3>

<p>상태 페이지 업데이트에서도 “기존 워크로드 복구는 진행되지만 새 워크로드 생성은 아직 어려울 수 있다”는 뉘앙스가 반복됩니다<sup id="fnref:1:16" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. control plane이 불안정할 때는 이런 현상이 흔합니다.</p>

<p>따라서 멀티존/멀티리전 전환 설계에서 ‘필수’ 질문은 이것입니다.</p>

<ul>
  <li>장애 시점에 autoscaler가 scale out을 못 한다면, 현재 떠 있는 capacity만으로 얼마나 버틸 수 있는가?</li>
  <li>“존 하나가 날아간 상태의 steady state”를 기준으로 HPA/스케줄링/리밋을 잡았는가?</li>
</ul>

<p>이번 사건에서 Google이 “scheduler allocations and capacity limits”를 조정해 customer reservation을 우선시하고 queue backlog를 줄였다고 밝힌 것도 같은 맥락입니다<sup id="fnref:1:17" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<h3 id="용량-계층-failover-순간의-최대-부하를-평시의-2배-이상으로-잡아야-한다">용량 계층: failover 순간의 최대 부하를 평시의 2배 이상으로 잡아야 한다</h3>

<p>europe-west2-b가 꺼지자 남은 존이 다음을 한꺼번에 떠안았습니다.</p>

<ul>
  <li>redirected query execution</li>
  <li>database HA failover</li>
  <li>compute workload failover</li>
</ul>

<p>그리고 그 결과가 “compute slots/processing capacity 포화 → admission queue 상승 → 500/503”입니다<sup id="fnref:1:18" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<p>멀티존 전환이 “그냥 다른 존으로 간다”라고 생각되기 쉬운데, 실제로는 “한동안 훨씬 더 비싼 방식으로 돌아간다”에 가깝습니다.</p>

<ul>
  <li>캐시가 cold</li>
  <li>DB failover 중</li>
  <li>큐 backlog</li>
  <li>관측/알림/대시보드도 장애</li>
</ul>

<p>정리하면, 멀티존/멀티리전은 비용을 추가로 내고 용량을 비워두는 설계가 아니라, 장애 시점에 처리해야 하는 “추가 작업”을 위한 공간을 확보하는 설계입니다.</p>

<h3 id="데이터-계층-존-장애와-리전-장애가-요구하는-복제-형태가-다르다">데이터 계층: ‘존 장애’와 ‘리전 장애’가 요구하는 복제 형태가 다르다</h3>

<p>Spanner 문서는 regional configuration이 “3개의 read-write replica를 서로 다른 zone에 둔다”고 설명하며, single-zone failure에 대한 가용성을 기본으로 포함한다고 말합니다<sup id="fnref:10" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">10</a></sup>.</p>

<p>하지만 리전 단 장애까지 상정하면 multi-region/dual-region이 필요해지고, 가용성/비용/지연시간 trade-off가 급격히 커집니다. Spanner는 multi-region에서 “regional failure에도 availability를 유지할 수 있고 더 높은 SLA를 제공”한다고 문서에 직접 적습니다<sup id="fnref:10:1" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">10</a></sup>.</p>

<p>결국 애플리케이션 설계는 다음 중 하나를 선택해야 합니다.</p>

<ul>
  <li>데이터베이스 자체가 멀티리전 quorum을 형성(강한 일관성 유지, 비용/지연 증가)</li>
  <li>데이터는 단일 리전에 두되 애플리케이션 레벨에서 degrade(읽기 전용, 제한 모드)</li>
  <li>국가/규제 요구가 있으면 dual-region처럼 “국내 2리전” 패턴을 선택(제품/에디션 제약 가능)</li>
</ul>

<p>멀티리전이 필요하다는 결론은 쉬운데, 어떤 형태의 멀티리전이 ‘비즈니스가 감당 가능한 비용’인지가 더 어렵습니다.</p>

<h3 id="클라이언트sdk-계층-재시도는-성공률을-올리는-장치가-아니라-장애를-키우는-장치가-될-수-있다">클라이언트/SDK 계층: 재시도는 ‘성공률을 올리는 장치’가 아니라 ‘장애를 키우는 장치’가 될 수 있다</h3>

<p>Google SRE는 재시도가 cascading failure를 유발할 수 있고, randomized exponential backoff를 쓰라고 강조합니다<sup id="fnref:4:1" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>. Google Cloud 문서도 thundering herd를 직접 언급하며 jitter가 필요하다고 말합니다<sup id="fnref:5:1" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>.</p>

<p>운영에서 체크할 항목은 구현 디테일입니다.</p>

<ul>
  <li>timeout은 hop마다 어떻게 배분되는가(엔드투엔드 deadline을 먼저 정했는가)</li>
  <li>retry는 몇 번인가(무한 재시도가 있는가)</li>
  <li>retry는 어떤 에러에 적용되는가(503만? timeout도?)</li>
  <li>write 요청에는 idempotency key가 있는가</li>
  <li>circuit breaker/load shedding이 있는가</li>
</ul>

<p>이 항목들이 빠지면, 멀티리전 전환이 있어도 장애 리전에 계속 두드리다가 정상 리전까지 무너뜨리는 그림이 나옵니다.</p>

<h3 id="관측-계층-장애-시점에-같은-리전에서-도는-모니터링은-같이-죽는다">관측 계층: 장애 시점에 “같은 리전에서 도는 모니터링”은 같이 죽는다</h3>

<p>Google Cloud는 Service Health 대시보드 외에도 Personalized Service Health를 언급합니다<sup id="fnref:11" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup>. 또한 문서로 “Personalized Service Health를 연동”하는 방법을 제공합니다<sup id="fnref:12" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup>.</p>

<p>하지만 공급자 내부 대시보드만으로는 초기 탐지가 늦어질 수 있습니다. 장애를 빨리 감지하고 전환을 실행하려면, 최소한 다음은 필요합니다.</p>

<ul>
  <li>서로 다른 리전/외부 네트워크에서의 synthetic probe</li>
  <li>고객 트래픽과 동일한 critical path(로그인/결제/조회 등) 기반 SLI</li>
  <li>장애 시 자동으로 전환을 수행하는 runbook(혹은 자동화)</li>
</ul>

<p>이번 사건에서도 Google은 “automated monitoring systems and probers”로 23:08 PDT에 감지했다고 명시합니다<sup id="fnref:1:19" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. 고객도 같은 형태의 “외부에서 보는 프로버”가 있어야 전환이 빨라집니다.</p>

<h2 id="반론과-회의론-멀티존멀티리전이-만능이-아닌-이유">반론과 회의론: 멀티존/멀티리전이 만능이 아닌 이유</h2>

<p>1) 멀티리전은 비용이 아니라 운영 복잡도를 삽니다. 트래픽 전환, 데이터 복제, 배포 파이프라인, 권한 모델, 사고 대응까지 전부가 두 배로 느껴질 수 있습니다.</p>

<p>2) Global external Application Load Balancer 같은 글로벌 구성도 결국은 제어플레인/데이터플레인 복합체입니다. 문서는 global external Application Load Balancer가 Premium tier에서 “가장 가까운 healthy backend로 라우팅”한다고 설명하지만<sup id="fnref:8:1" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup>, “healthy”의 정의가 애플리케이션 readiness와 맞지 않으면 전환은 늦거나 잘못됩니다.</p>

<p>3) 데이터는 애플리케이션보다 옮기기 어렵습니다. 특히 강한 일관성을 유지하는 write 경로는 멀티리전으로 갈수록 latency/비용이 커집니다. Spanner가 multi-region에서 더 높은 SLA(예: 99.999%)를 제공한다고 설명하는 대신<sup id="fnref:13" role="doc-noteref"><a href="#fn:13" class="footnote" rel="footnote">13</a></sup>, 그만큼 구조적 비용이 들어갑니다.</p>

<p>4) “우리 서비스는 멀티리전이니까 괜찮다”는 안도감이 오히려 리트라이/백프레셔/큐잉 설계의 허점을 방치하게 만들기도 합니다. 이번 incident의 확산 경로는, 전환 장치가 있어도 리트라이 증폭과 용량 포화를 제어하지 못하면 리전 전반이 느려질 수 있음을 보여줍니다<sup id="fnref:1:20" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<h2 id="최종-rca에서-확인하고-싶은-질문들">최종 RCA에서 확인하고 싶은 질문들</h2>

<p>Preliminary Incident Report만으로도 큰 그림은 잡히지만, 설계 관점에서 더 알고 싶은 질문이 남습니다.</p>

<ul>
  <li>regional service control plane이 의존하던 metadata/catalog 컴포넌트는 왜 오프라인 존에 있었는가. 복제/쿼럼 구조였는데도 timeout이 전파된 것인지, 혹은 특정 강결합 경로가 있었는가<sup id="fnref:1:21" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li>“traffic isolation”이 구체적으로 어떤 레벨에서 이뤄졌는가. 고객 트래픽만 차단한 것인지, 제어플레인의 cross-zone 호출까지 잘라냈는가<sup id="fnref:1:22" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li>compute slot 포화가 발생했을 때 admission control 정책은 어떤 식으로 동작했고, 어떤 워크로드가 우선순위를 가져갔는가. 특히 regional service와 zonal 서비스의 공존에서 starvation이 있었는가.</li>
  <li>controlled traffic re-admission은 어느 정도 속도로 이뤄졌고, 그 속도를 결정하는 신호는 무엇이었는가<sup id="fnref:1:23" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
</ul>

<p>이 답이 나오면, 고객 설계에서 “어떤 종류의 멀티존/멀티리전이 실제로 리스크를 줄이는지”가 더 명확해집니다.</p>

<h2 id="지금-당장-점검할-수-있는-항목-전환이-가능이-아니라-즉시인가">지금 당장 점검할 수 있는 항목: 전환이 ‘가능’이 아니라 ‘즉시’인가</h2>

<p>이번 사건을 보면서, 멀티존/멀티리전 설계를 실제로 동작하게 만드는 최소 점검 항목을 다시 정리했습니다.</p>

<ul>
  <li>전환 목표 시간: 장애 감지부터 1분/5분/15분 중 어디를 목표로 하는가. 목표가 없으면 timeout/retry만 길어집니다.</li>
  <li>readiness 설계: dependency 불능을 readiness에 반영하는가. 500을 뿜는 상태로 “healthy”가 유지되면 전환은 실패합니다.</li>
  <li>retry budget: 엔드투엔드 deadline과 최대 재시도 횟수가 정해져 있는가. hop마다 따로 재시도하면 폭발합니다.</li>
  <li>failover 순간의 capacity: “한 존/한 리전이 사라졌을 때 남은 곳이 failover 작업까지 포함해 감당 가능한가”를 수치로 확인했는가. 이번 incident는 정상 존이 failover 트래픽으로 포화될 수 있음을 명시합니다<sup id="fnref:1:24" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
  <li>관측의 분리: 모니터링/알림이 같은 리전/같은 VPC에 갇혀 있지 않은가.</li>
  <li>전환 후 복귀(failback): 복구 직후에 트래픽을 공격적으로 다시 원래 리전에 몰아넣지 않는가. 공급자도 controlled re-admission을 강조합니다<sup id="fnref:1:25" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</li>
</ul>

<p>이번 europe-west2 사건은 전원 장애 자체보다, “단일 존 장애가 제어플레인과 용량, 재시도 증폭을 타고 리전 장애처럼 관측되는 과정”이 더 많은 힌트를 줍니다. 페일오버는 아키텍처 다이어그램이 아니라, 타임아웃/헬스/용량/재시도를 동시에 설계했을 때만 동작합니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li>
    <table>
      <tbody>
        <tr>
          <td>[Incident details</td>
          <td>Google Cloud Service Health](https://status.cloud.google.com/incidents/Xs2XTHzAVC9YnafnPyzj)</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <table>
      <tbody>
        <tr>
          <td>[Google Cloud outage hits UK banks</td>
          <td>AP News](https://apnews.com/article/google-outage-europe-bank-apps-e8d708a22cb1a1b225e2b1b70e963a31)</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <table>
      <tbody>
        <tr>
          <td>[Serve traffic from multiple regions</td>
          <td>Cloud Run](https://docs.cloud.google.com/run/docs/multiple-regions?hl=en)</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li><a href="https://docs.cloud.google.com/run/docs/configuring/configure-service-health?hl=en">Automate cross-region failover with Cloud Run service health</a></li>
  <li><a href="https://docs.cloud.google.com/load-balancing/docs/https">External Application Load Balancer overview</a></li>
  <li><a href="https://docs.cloud.google.com/dns/docs/routing-policies-overview">DNS routing policies and health checks</a></li>
  <li>
    <table>
      <tbody>
        <tr>
          <td>[Retry failed requests</td>
          <td>IAM](https://docs.cloud.google.com/iam/docs/retry-strategy?hl=en)</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li><a href="https://sre.google/sre-book/service-best-practices/">Production Services Best Practices</a></li>
  <li><a href="https://sre.google/sre-book/addressing-cascading-failures/">Addressing Cascading Failures</a></li>
  <li>
    <table>
      <tbody>
        <tr>
          <td>[Regional, dual-region, and multi-region configurations</td>
          <td>Spanner](https://docs.cloud.google.com/spanner/docs/instance-configurations?hl=en)</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li><a href="https://cloud.google.com/spanner/sla">Cloud Spanner Service Level Agreement (SLA)</a></li>
  <li><a href="https://docs.cloud.google.com/service-health/docs/how-to-integrate">Integrate with Personalized Service Health</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://status.cloud.google.com/incidents/Xs2XTHzAVC9YnafnPyzj">https://status.cloud.google.com/incidents/Xs2XTHzAVC9YnafnPyzj</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:1:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:1:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:1:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:1:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:1:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a> <a href="#fnref:1:9" class="reversefootnote" role="doc-backlink">&#8617;<sup>10</sup></a> <a href="#fnref:1:10" class="reversefootnote" role="doc-backlink">&#8617;<sup>11</sup></a> <a href="#fnref:1:11" class="reversefootnote" role="doc-backlink">&#8617;<sup>12</sup></a> <a href="#fnref:1:12" class="reversefootnote" role="doc-backlink">&#8617;<sup>13</sup></a> <a href="#fnref:1:13" class="reversefootnote" role="doc-backlink">&#8617;<sup>14</sup></a> <a href="#fnref:1:14" class="reversefootnote" role="doc-backlink">&#8617;<sup>15</sup></a> <a href="#fnref:1:15" class="reversefootnote" role="doc-backlink">&#8617;<sup>16</sup></a> <a href="#fnref:1:16" class="reversefootnote" role="doc-backlink">&#8617;<sup>17</sup></a> <a href="#fnref:1:17" class="reversefootnote" role="doc-backlink">&#8617;<sup>18</sup></a> <a href="#fnref:1:18" class="reversefootnote" role="doc-backlink">&#8617;<sup>19</sup></a> <a href="#fnref:1:19" class="reversefootnote" role="doc-backlink">&#8617;<sup>20</sup></a> <a href="#fnref:1:20" class="reversefootnote" role="doc-backlink">&#8617;<sup>21</sup></a> <a href="#fnref:1:21" class="reversefootnote" role="doc-backlink">&#8617;<sup>22</sup></a> <a href="#fnref:1:22" class="reversefootnote" role="doc-backlink">&#8617;<sup>23</sup></a> <a href="#fnref:1:23" class="reversefootnote" role="doc-backlink">&#8617;<sup>24</sup></a> <a href="#fnref:1:24" class="reversefootnote" role="doc-backlink">&#8617;<sup>25</sup></a> <a href="#fnref:1:25" class="reversefootnote" role="doc-backlink">&#8617;<sup>26</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://apnews.com/article/google-outage-europe-bank-apps-e8d708a22cb1a1b225e2b1b70e963a31">https://apnews.com/article/google-outage-europe-bank-apps-e8d708a22cb1a1b225e2b1b70e963a31</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://sre.google/sre-book/service-best-practices/">https://sre.google/sre-book/service-best-practices/</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://sre.google/sre-book/addressing-cascading-failures/">https://sre.google/sre-book/addressing-cascading-failures/</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:4:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/iam/docs/retry-strategy?hl=en">https://docs.cloud.google.com/iam/docs/retry-strategy?hl=en</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:5:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/run/docs/multiple-regions?hl=en">https://docs.cloud.google.com/run/docs/multiple-regions?hl=en</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:6:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:6:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:6:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/run/docs/configuring/configure-service-health?hl=en">https://docs.cloud.google.com/run/docs/configuring/configure-service-health?hl=en</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:7:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:7:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/load-balancing/docs/https">https://docs.cloud.google.com/load-balancing/docs/https</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:8:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/dns/docs/routing-policies-overview">https://docs.cloud.google.com/dns/docs/routing-policies-overview</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:10" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/spanner/docs/instance-configurations?hl=en">https://docs.cloud.google.com/spanner/docs/instance-configurations?hl=en</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:10:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:11" role="doc-endnote">
      <p><a href="https://status.cloud.google.com/index.html">https://status.cloud.google.com/index.html</a> <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:12" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/service-health/docs/how-to-integrate">https://docs.cloud.google.com/service-health/docs/how-to-integrate</a> <a href="#fnref:12" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:13" role="doc-endnote">
      <p><a href="https://cloud.google.com/spanner/sla">https://cloud.google.com/spanner/sla</a> <a href="#fnref:13" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="News" /><category term="Cloud" /><category term="google-cloud" /><category term="outage-analysis" /><category term="multi-region" /><category term="cloud-run" /><category term="load-balancing" /><category term="retries" /><summary type="html"><![CDATA[존 단 전원 장애가 제어플레인 의존성과 용량 포화, 리트라이 증폭을 타고 리전 장애처럼 보이는 과정을 복기하고, 멀티존·멀티리전 전환을 실제로 동작시키는 체크리스트를 정리합니다.]]></summary></entry><entry><title type="html">Apache Camel 보안 권고 38건 한 번에 소화하기: 컴포넌트별 영향도 기반 전략</title><link href="https://joyuno.github.io/blog/2026/10/26/camel-advisories-component-upgrade-strategy/" rel="alternate" type="text/html" title="Apache Camel 보안 권고 38건 한 번에 소화하기: 컴포넌트별 영향도 기반 전략" /><published>2026-10-26T09:53:29+09:00</published><updated>2026-10-26T09:53:29+09:00</updated><id>https://joyuno.github.io/blog/2026/10/26/camel-advisories-component-upgrade-strategy</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/26/camel-advisories-component-upgrade-strategy/"><![CDATA[<h2 id="38건-일괄-공지의-핵심은-camel이-아니라-컴포넌트입니다">38건 ‘일괄 공지’의 핵심은 Camel이 아니라 컴포넌트입니다</h2>

<p>Apache Camel PMC는 2026-10-15에 38개 security advisory를 공개했고, 다음날(2026-10-16) 이를 한 번에 묶어 공지했습니다. 고정 버전은 4.18.5(4.18.x LTS), 4.22.1(4.22.x LTS), 4.23.0이며, 13건이 HIGH, 25건이 MEDIUM으로 분류되어 있습니다. 또한 4.14.x LTS는 4.14.9 이후 EOL이라 이번 수정의 대부분(공지 기준 35/38)에 포함되어도 더 이상 패치가 내려오지 않는다고 못 박았습니다.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이 유형의 사건에서 조직이 흔히 하는 실수는 “Camel을 올리자/말자”로 바로 점프하는 것입니다. Camel은 프레임워크이지만 운영에서 부딪히는 취약점의 현실은 <strong>컴포넌트별로 터지고</strong>, 서비스는 그 컴포넌트의 일부만 쓰며, 취약점의 전제조건도 제각각입니다.</p>

<p>예를 들어 이번 묶음만 봐도 성격이 완전히 다릅니다.</p>

<ul>
  <li>HTTP producer 계열에서 <code class="language-plaintext highlighter-rouge">CamelHttpUri</code>/<code class="language-plaintext highlighter-rouge">CamelRestHttpUri</code> override header에 대해 property placeholder를 풀어버리던 문제(CVE-2026-78164)는 “헤더/플레이스홀더/secret function/registry bean method”가 얽힌 설정 실행 문제입니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li><code class="language-plaintext highlighter-rouge">camel-spring-redis</code> 기본 serializer가 Redis payload를 <code class="language-plaintext highlighter-rouge">ObjectInputFilter</code> 없이 Java deserialization 하던 문제(CVE-2026-78165)는 “메시지 도착 직후, 라우트 로직 이전”에 sink가 열려 있던 케이스입니다.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
  <li><code class="language-plaintext highlighter-rouge">camel-ftp</code>, <code class="language-plaintext highlighter-rouge">camel-mina-sftp</code>, <code class="language-plaintext highlighter-rouge">camel-smb</code>, <code class="language-plaintext highlighter-rouge">camel-azure-files</code>의 path traversal(CVE-2026-79983~79986)은 “remote directory listing을 신뢰하면 안 된다”는 파일/스토리지 계열 취약점입니다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
  <li><code class="language-plaintext highlighter-rouge">camel-oauth</code>의 fail-open 인증 우회(CVE-2026-80312)나 <code class="language-plaintext highlighter-rouge">camel-netty-http</code> security constraint bypass(CVE-2026-80343)는 경계(consumer 앞단) 설계가 그대로 드러납니다.<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
</ul>

<p>같은 “Camel 보안 이슈”로 묶였지만, 서비스에 미치는 영향도, 긴급도, 테스트 범위, 즉시 가능한 차단책이 다릅니다. 그래서 우선순위를 정하려면 CVE를 서비스에 투영하는 ‘인벤토리 → 노출 경로 → 업그레이드/차단’ 파이프라인이 필요합니다.</p>

<h2 id="서비스별-컴포넌트-인벤토리를-먼저-확정해야-하는-이유">‘서비스별 컴포넌트 인벤토리’를 먼저 확정해야 하는 이유</h2>

<p>Camel의 보안 공지는 컴포넌트 단위로 나옵니다. 예를 들어 공지 표에서 CVE-2026-78164는 <code class="language-plaintext highlighter-rouge">camel-http</code>, <code class="language-plaintext highlighter-rouge">camel-netty-http</code>, <code class="language-plaintext highlighter-rouge">camel-undertow</code>, <code class="language-plaintext highlighter-rouge">camel-vertx-http</code>에 걸려 있습니다.<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이때 “우리 서비스는 Camel 쓰니까 영향 있음”이 아니라 다음을 분리해야 합니다.</p>

<ol>
  <li><strong>빌드 타임 포함 여부</strong>: 실제로 <code class="language-plaintext highlighter-rouge">camel-netty-http</code> 같은 artifact가 classpath에 있나?</li>
  <li><strong>라우트에서 사용 여부</strong>: artifact가 있어도 해당 엔드포인트를 안 쓰면 노출면이 줄어든다.</li>
  <li><strong>신뢰 경계(Trust boundary)</strong>: 라우트로 들어오는 메시지가 외부/내부/파트너/멀티테넌트인지.</li>
  <li><strong>취약점 전제조건 충족 여부</strong>: 예를 들어 CVE-2026-83531은 <code class="language-plaintext highlighter-rouge">dynamic-router-control:</code>로 메시지가 들어갈 수 있어야 의미가 있습니다.<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
</ol>

<p>Camel이 자체적으로 “보안 모델/보고 범위/신뢰 경계”를 문서화한 것도 같은 맥락입니다. 무엇이 프레임워크 취약점이고 무엇이 운영자/라우트 작성자 책임인지 분리해 triage에 쓰라는 의도입니다.<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></p>

<p>조직 관점에서 내가 제일 먼저 고정하는 산출물은 아래 2개입니다.</p>

<ul>
  <li>서비스별 Camel 컴포넌트 인벤토리(artifact 기준, endpoint scheme 기준)</li>
  <li>CVE별 “노출 경로 타입” 라벨링(placeholder, header, deserialize, path, authz/authn, management/control message 등)</li>
</ul>

<p>이 두 개가 있어야 “업그레이드가 곧 정답”이 아니라 “업그레이드 전 즉시 차단”과 “업그레이드 후 회귀 테스트”를 같이 설계할 수 있습니다.</p>

<h2 id="인벤토리-만들기-1-의존성에서-camel-컴포넌트-추출하기maven-기준">인벤토리 만들기 1: 의존성에서 Camel 컴포넌트 추출하기(Maven 기준)</h2>

<p>운영에서 가장 싸고 빠른 인벤토리는 빌드 산출물에서 나옵니다. Camel 컴포넌트는 보통 <code class="language-plaintext highlighter-rouge">org.apache.camel:camel-xxx</code> 혹은 Spring Boot를 쓰면 <code class="language-plaintext highlighter-rouge">org.apache.camel.springboot:camel-xxx-starter</code> 형태로 들어옵니다.</p>

<h3 id="1-maven-dependencylist로-camel-artifact만-뽑기">1) Maven dependency:list로 Camel artifact만 뽑기</h3>

<p>아래 커맨드는 “런타임 스코프 기준으로, groupId가 org.apache.camel/org.apache.camel.springboot 인 것만” 파일로 뽑습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 서비스 리포지토리 루트에서</span>
mvn <span class="nt">-q</span> <span class="se">\</span>
  <span class="nt">-DincludeScope</span><span class="o">=</span>runtime <span class="se">\</span>
  <span class="nt">-DincludeGroupIds</span><span class="o">=</span>org.apache.camel,org.apache.camel.springboot <span class="se">\</span>
  dependency:list <span class="se">\</span>
  <span class="nt">-DoutputFile</span><span class="o">=</span>target/camel-deps.txt

<span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'1,120p'</span> target/camel-deps.txt
</code></pre></div></div>

<p>이 파일은 포맷이 단순해서 스크립트로 파싱하기 좋습니다.</p>

<h3 id="2-파싱-스크립트-camel-component-set-만들기">2) 파싱 스크립트: camel component set 만들기</h3>

<p>아래는 <code class="language-plaintext highlighter-rouge">camel-deps.txt</code>에서 Camel 컴포넌트 후보를 정규화해 <code class="language-plaintext highlighter-rouge">components.json</code>으로 저장하는 예시입니다. (starter면 <code class="language-plaintext highlighter-rouge">-starter</code>를 떼고, <code class="language-plaintext highlighter-rouge">camel-core</code> 같은 코어 모듈은 별도 처리)</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tools/camel_inventory_from_maven.py
</span><span class="kn">import</span> <span class="nn">json</span>
<span class="kn">import</span> <span class="nn">re</span>
<span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="n">COMP_RE</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">"\borg\.apache\.camel(\.springboot)?:camel-([a-z0-9-]+)(-starter)?:"</span><span class="p">)</span>

<span class="n">CORE_LIKE</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"core"</span><span class="p">,</span>
    <span class="s">"base"</span><span class="p">,</span>
    <span class="s">"api"</span><span class="p">,</span>
    <span class="s">"main"</span><span class="p">,</span>
    <span class="s">"management"</span><span class="p">,</span>
    <span class="s">"util"</span><span class="p">,</span>
    <span class="s">"support"</span><span class="p">,</span>
    <span class="s">"dsl-modeline"</span><span class="p">,</span>
    <span class="s">"dsl-support"</span><span class="p">,</span>
    <span class="s">"core-languages"</span><span class="p">,</span>
<span class="p">}</span>

<span class="n">inp</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
<span class="n">components</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
<span class="n">core_modules</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>

<span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">inp</span><span class="p">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">,</span> <span class="n">errors</span><span class="o">=</span><span class="s">"ignore"</span><span class="p">).</span><span class="n">splitlines</span><span class="p">():</span>
    <span class="n">m</span> <span class="o">=</span> <span class="n">COMP_RE</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">line</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span><span class="p">:</span>
        <span class="k">continue</span>
    <span class="n">name</span> <span class="o">=</span> <span class="n">m</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
    <span class="c1"># camel-ftp -&gt; ftp, camel-netty-http -&gt; netty-http
</span>    <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">CORE_LIKE</span><span class="p">:</span>
        <span class="n">core_modules</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">components</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>

<span class="n">out</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"components"</span><span class="p">:</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">components</span><span class="p">),</span>
    <span class="s">"core_modules"</span><span class="p">:</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">core_modules</span><span class="p">),</span>
<span class="p">}</span>
<span class="k">print</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">out</span><span class="p">,</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="bp">False</span><span class="p">))</span>
</code></pre></div></div>

<p>실행:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 tools/camel_inventory_from_maven.py target/camel-deps.txt <span class="o">&gt;</span> target/camel-components.json
<span class="nb">cat </span>target/camel-components.json
</code></pre></div></div>

<p>여기서 주의할 점이 있습니다.</p>

<ul>
  <li>Spring Boot starter는 transitive dependency로 실제 <code class="language-plaintext highlighter-rouge">camel-xxx</code>를 당겨옵니다. “starter만 있다/없다”보다 “실제 컴포넌트 모듈이 무엇이냐”를 봐야 합니다.</li>
  <li>BOM(<code class="language-plaintext highlighter-rouge">camel-bom</code>, <code class="language-plaintext highlighter-rouge">camel-spring-boot-bom</code>)로 버전을 고정하면 artifact 목록은 명확해지지만, 모듈이 많아져도 취약점 노출이 늘어난다고 단정하면 안 됩니다. 노출은 ‘사용/경계’가 좌우합니다.</li>
</ul>

<h2 id="인벤토리-만들기-2-라우트-정의에서-endpoint-scheme를-추출하기">인벤토리 만들기 2: 라우트 정의에서 endpoint scheme를 추출하기</h2>

<p>의존성 인벤토리는 “포함된 칼”을 세는 것이고, 라우트 인벤토리는 “실제로 휘두르는 칼”을 세는 것입니다.</p>

<p>Camel은 URI scheme가 곧 컴포넌트인 경우가 많습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ftp://...</code> → <code class="language-plaintext highlighter-rouge">camel-ftp</code></li>
  <li><code class="language-plaintext highlighter-rouge">smb://...</code> → <code class="language-plaintext highlighter-rouge">camel-smb</code></li>
  <li><code class="language-plaintext highlighter-rouge">spring-redis://...</code> → <code class="language-plaintext highlighter-rouge">camel-spring-redis</code></li>
  <li><code class="language-plaintext highlighter-rouge">netty-http:http://...</code> → <code class="language-plaintext highlighter-rouge">camel-netty-http</code></li>
</ul>

<p>라우트가 Java DSL, XML DSL, YAML DSL로 섞여 있으면 정적 분석이 완벽하지는 않습니다. 그래도 “업그레이드 우선순위 산정” 용도로는 충분히 쓸 만합니다.</p>

<h3 id="1-ripgrep으로-scheme-후보-모으기">1) ripgrep으로 scheme 후보 모으기</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rg <span class="nt">-n</span> <span class="nt">--hidden</span> <span class="nt">--no-ignore-vcs</span> <span class="se">\</span>
  <span class="nt">-g</span><span class="s1">'!**/target/**'</span> <span class="se">\</span>
  <span class="nt">-g</span><span class="s1">'!**/build/**'</span> <span class="se">\</span>
  <span class="nt">-e</span> <span class="s1">'"[a-z][a-z0-9+.-]*:'</span> <span class="se">\</span>
  <span class="nt">-e</span> <span class="s2">"'[a-z][a-z0-9+.-]*:"</span> <span class="se">\</span>
  <span class="nt">-e</span> <span class="s1">'uri:\s*"[a-z][a-z0-9+.-]*:'</span> <span class="se">\</span>
  <span class="nt">-e</span> <span class="s1">'to:\s*"[a-z][a-z0-9+.-]*:'</span> <span class="se">\</span>
  <span class="nb">.</span> <span class="o">&gt;</span> target/camel-uri-hits.txt

<span class="nb">head</span> <span class="nt">-n</span> 50 target/camel-uri-hits.txt
</code></pre></div></div>

<p>이 결과에서 <code class="language-plaintext highlighter-rouge">direct:</code>, <code class="language-plaintext highlighter-rouge">log:</code>, <code class="language-plaintext highlighter-rouge">timer:</code> 같은 내부용 scheme는 제외하고, <code class="language-plaintext highlighter-rouge">ftp</code>, <code class="language-plaintext highlighter-rouge">netty-http</code>, <code class="language-plaintext highlighter-rouge">undertow</code>, <code class="language-plaintext highlighter-rouge">vertx-http</code>, <code class="language-plaintext highlighter-rouge">spring-redis</code>, <code class="language-plaintext highlighter-rouge">hazelcast</code> 같은 외부 I/O 혹은 보안 경계 컴포넌트만 우선 태깅합니다.</p>

<h3 id="2-왜-정적-분석이-불완전한가">2) 왜 정적 분석이 불완전한가</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">toD()</code>/<code class="language-plaintext highlighter-rouge">enrich()</code>/<code class="language-plaintext highlighter-rouge">recipientList()</code> 같은 dynamic endpoint는 런타임에 URI가 조합됩니다.</li>
  <li><code class="language-plaintext highlighter-rouge">CamelHttpUri</code>처럼 헤더로 URI override가 가능한 컴포넌트는 라우트 코드만 봐서는 실제 outbound target을 확정할 수 없습니다. CVE-2026-78164가 바로 이 경로를 악용합니다.<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<p>그래도 “무슨 컴포넌트가 서비스에 들어있고, 대충 어느 지점에서 쓰이는지”를 확보하면 CVE 묶음 triage가 훨씬 싸집니다.</p>

<h2 id="advisory-데이터셋-만들기-공지-표를-그대로-머신이-읽게-바꾸기">advisory 데이터셋 만들기: 공지 표를 그대로 ‘머신이 읽게’ 바꾸기</h2>

<p>Camel 쪽 공지에는 38건의 CVE/컴포넌트/Severity/요약이 표로 정리돼 있습니다.<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>조직 내부에서 실제로 필요한 것은 다음 형태입니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CVE-XXXX-YYYYY</code> → 영향 컴포넌트 리스트</li>
  <li>Severity(HIGH/MEDIUM)</li>
  <li>전제조건(가능하면)</li>
  <li>조치(업그레이드/설정 변경/차단)</li>
</ul>

<h3 id="1-공지-페이지에서-표를-추출하는-스크립트">1) 공지 페이지에서 표를 추출하는 스크립트</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tools/fetch_camel_38_advisories.py
</span><span class="kn">import</span> <span class="nn">json</span>
<span class="kn">import</span> <span class="nn">re</span>

<span class="kn">import</span> <span class="nn">requests</span>
<span class="kn">from</span> <span class="nn">bs4</span> <span class="kn">import</span> <span class="n">BeautifulSoup</span>

<span class="n">URL</span> <span class="o">=</span> <span class="s">"https://camel.apache.org/blog/2026/10/camel-security-advisories-4.23.0/"</span>

<span class="k">def</span> <span class="nf">split_components</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="c1"># 예: "camel-http, camel-netty-http, camel-undertow, camel-vertx-http"
</span>    <span class="n">parts</span> <span class="o">=</span> <span class="p">[</span><span class="n">p</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">text</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">","</span><span class="p">)]</span>
    <span class="c1"># camel- 접두 제거 없이 유지 (Camel 측 표와 맞추기)
</span>    <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">parts</span> <span class="k">if</span> <span class="n">p</span><span class="p">]</span>

<span class="n">resp</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">URL</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
<span class="n">resp</span><span class="p">.</span><span class="n">raise_for_status</span><span class="p">()</span>

<span class="n">soup</span> <span class="o">=</span> <span class="n">BeautifulSoup</span><span class="p">(</span><span class="n">resp</span><span class="p">.</span><span class="n">text</span><span class="p">,</span> <span class="s">"lxml"</span><span class="p">)</span>

<span class="c1"># 본문 첫 번째 표가 advisory 표인 형태(페이지가 변하면 selector 조정 필요)
</span><span class="n">table</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">"table"</span><span class="p">)</span>
<span class="k">if</span> <span class="n">table</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nb">SystemExit</span><span class="p">(</span><span class="s">"advisory table not found"</span><span class="p">)</span>

<span class="n">rows</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">for</span> <span class="n">tr</span> <span class="ow">in</span> <span class="n">table</span><span class="p">.</span><span class="n">find_all</span><span class="p">(</span><span class="s">"tr"</span><span class="p">):</span>
    <span class="n">tds</span> <span class="o">=</span> <span class="n">tr</span><span class="p">.</span><span class="n">find_all</span><span class="p">([</span><span class="s">"td"</span><span class="p">,</span> <span class="s">"th"</span><span class="p">])</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">tds</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">4</span><span class="p">:</span>
        <span class="k">continue</span>

    <span class="n">cve</span> <span class="o">=</span> <span class="n">tds</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">get_text</span><span class="p">(</span><span class="n">strip</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">re</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="sa">r</span><span class="s">"CVE-\d{4}-\d+"</span><span class="p">,</span> <span class="n">cve</span><span class="p">):</span>
        <span class="k">continue</span>

    <span class="n">component</span> <span class="o">=</span> <span class="n">tds</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="n">get_text</span><span class="p">(</span><span class="s">" "</span><span class="p">,</span> <span class="n">strip</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">severity</span> <span class="o">=</span> <span class="n">tds</span><span class="p">[</span><span class="mi">2</span><span class="p">].</span><span class="n">get_text</span><span class="p">(</span><span class="n">strip</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">desc</span> <span class="o">=</span> <span class="n">tds</span><span class="p">[</span><span class="mi">3</span><span class="p">].</span><span class="n">get_text</span><span class="p">(</span><span class="s">" "</span><span class="p">,</span> <span class="n">strip</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="n">rows</span><span class="p">.</span><span class="n">append</span><span class="p">({</span>
        <span class="s">"cve"</span><span class="p">:</span> <span class="n">cve</span><span class="p">,</span>
        <span class="s">"components"</span><span class="p">:</span> <span class="n">split_components</span><span class="p">(</span><span class="n">component</span><span class="p">),</span>
        <span class="s">"severity"</span><span class="p">:</span> <span class="n">severity</span><span class="p">,</span>
        <span class="s">"summary"</span><span class="p">:</span> <span class="n">desc</span><span class="p">,</span>
        <span class="s">"source"</span><span class="p">:</span> <span class="n">URL</span><span class="p">,</span>
    <span class="p">})</span>

<span class="k">print</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span><span class="s">"advisories"</span><span class="p">:</span> <span class="n">rows</span><span class="p">},</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="bp">False</span><span class="p">))</span>
</code></pre></div></div>

<p>실행:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 <span class="nt">-m</span> venv .venv
<span class="nb">source</span> .venv/bin/activate
pip <span class="nb">install </span>requests beautifulsoup4 lxml

python tools/fetch_camel_38_advisories.py <span class="o">&gt;</span> target/camel-38.json
jq <span class="s1">'.advisories[0:5]'</span> target/camel-38.json
</code></pre></div></div>

<p>조직에서는 이 JSON을 기반으로 “서비스 인벤토리와 join”하는 단계를 붙이면 됩니다.</p>

<h3 id="2-서비스-컴포넌트와-cve를-join해서-영향도-리스트-만들기">2) 서비스 컴포넌트와 CVE를 join해서 ‘영향도 리스트’ 만들기</h3>

<ul>
  <li>서비스에서 나온 컴포넌트 목록: <code class="language-plaintext highlighter-rouge">ftp</code>, <code class="language-plaintext highlighter-rouge">spring-redis</code>, <code class="language-plaintext highlighter-rouge">netty-http</code> 같은 형태</li>
  <li>advisory에서 나온 컴포넌트: <code class="language-plaintext highlighter-rouge">camel-ftp</code>, <code class="language-plaintext highlighter-rouge">camel-spring-redis</code>, <code class="language-plaintext highlighter-rouge">camel-netty-http</code></li>
</ul>

<p>이 둘을 join할 때는 prefix만 맞추면 됩니다.</p>

<ul>
  <li>서비스 컴포넌트 <code class="language-plaintext highlighter-rouge">ftp</code> ↔ advisory 컴포넌트 <code class="language-plaintext highlighter-rouge">camel-ftp</code></li>
</ul>

<p>여기서부터는 조직 상황에 맞게 점수화를 합니다.</p>

<ul>
  <li>Severity(HIGH=3, MEDIUM=2)</li>
  <li>외부 입력 경계면(인터넷=3, 파트너=2, 내부=1)</li>
  <li>전제조건 난이도(무인증 reachable=3, 인증 필요=2, 내부-only=1)</li>
  <li>데이터/권한 영향(RCE=3, auth bypass=3, SSRF/secret leak=2, path traversal=2…)</li>
</ul>

<p>이 점수표는 정답이 아니라 “테스트/업그레이드 순서를 합의하는 언어”입니다.</p>

<h2 id="노출-경로-타입별로-설정-안전화를-분리하는-방식">노출 경로 타입별로 설정 안전화를 분리하는 방식</h2>

<p>Camel의 security advisory 묶음을 읽으면, 꽤 많은 케이스가 “취약점이 발생한 코드의 성격이 반복된다”는 느낌이 있습니다.</p>

<ul>
  <li>control header / internal header가 외부 입력과 섞이는 문제</li>
  <li>property placeholder가 message content에 적용되는 문제</li>
  <li>default serializer/deserializer가 안전장치 없이 열려 있는 문제</li>
  <li>path/redirect/case normalization 불일치 문제</li>
</ul>

<p>이런 유형은 “특정 CVE만 막자”보다 “경계에서 공통으로 안전화하자”가 비용 대비 효과가 좋습니다.</p>

<p>아래는 이번 38건 중 대표적인 것들을 중심으로, 내가 실제 운영에서 잡는 공통 안전화 레이어입니다.</p>

<h3 id="http-producer-override-header--placeholder-cve-2026-78164">HTTP producer override header + placeholder: CVE-2026-78164</h3>

<p>CVE-2026-78164의 설명은 꽤 직설적입니다.</p>

<ul>
  <li>HTTP producer 계열은 inbound message가 <code class="language-plaintext highlighter-rouge">CamelHttpUri</code>, <code class="language-plaintext highlighter-rouge">CamelRestHttpUri</code> 헤더로 endpoint URI를 override할 수 있습니다.</li>
  <li>그런데 이 헤더 값을 <code class="language-plaintext highlighter-rouge">CamelContext.resolvePropertyPlaceholders()</code>에 태워버렸고,</li>
  <li>placeholder resolution은 단순 치환이 아니라 env var, system property, classpath의 <code class="language-plaintext highlighter-rouge">PropertiesFunction</code>(AWS/Azure/GCP/Vault/Kubernetes secret function 포함), 심지어 registry bean의 no-arg method 호출까지 닿습니다.</li>
  <li>즉, message content가 “설정/secret 평가 컨텍스트”로 승격되는 형태였습니다.<sup id="fnref:2:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<p>여기서 중요한 포인트는 두 가지입니다.</p>

<p>1) 라우트 작성자가 opt-in 하지 않아도 노출됩니다. 정적인 <code class="language-plaintext highlighter-rouge">.to("http://backend/api")</code>만 있어도, 앞단 어딘가에서 해당 헤더에 영향을 줄 수 있으면 게임이 시작됩니다.<sup id="fnref:2:3" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>2) <code class="language-plaintext highlighter-rouge">bridgeEndpoint=true</code> 같은 익숙한 설정이 “경계를 닫는다”로 직관적으로 읽히지만, 실제로는 그렇지 않았습니다. advisory는 <code class="language-plaintext highlighter-rouge">bridgeEndpoint</code>가 URL 생성 경로 일부만 막고, method/query 처리 경로는 막지 못했다고 설명합니다.<sup id="fnref:2:4" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<h4 id="즉시-차단업그레이드-전-신뢰-경계에서-헤더-제거">즉시 차단(업그레이드 전): 신뢰 경계에서 헤더 제거</h4>

<p>업그레이드를 당장 못 하는 경우 advisory가 제시하는 현실적인 방법은 “producer로 보내기 전에 헤더를 제거”하는 것입니다.<sup id="fnref:2:5" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>Camel Route 레벨에서 내가 주로 쓰는 패턴은 다음입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Java DSL 예시: HTTP producer로 보내기 전에 override 헤더를 무조건 제거</span>
<span class="n">from</span><span class="o">(</span><span class="s">"platform-http:/api/proxy"</span><span class="o">)</span>
  <span class="c1">// inbound가 인터넷이라면 여기부터가 trust boundary</span>
  <span class="o">.</span><span class="na">removeHeader</span><span class="o">(</span><span class="s">"CamelHttpUri"</span><span class="o">)</span>
  <span class="o">.</span><span class="na">removeHeader</span><span class="o">(</span><span class="s">"CamelRestHttpUri"</span><span class="o">)</span>
  <span class="o">.</span><span class="na">to</span><span class="o">(</span><span class="s">"netty-http:http://internal-backend/service"</span><span class="o">)</span>
  <span class="o">.</span><span class="na">to</span><span class="o">(</span><span class="s">"log:proxy?level=INFO"</span><span class="o">);</span>
</code></pre></div></div>

<p>이건 “해당 라우트만” 보호합니다. 서비스가 크면 매번 넣기 싫어집니다.</p>

<p>그래서 실제 운영에서는 아래 둘 중 하나로 정리합니다.</p>

<ul>
  <li>(권장) <strong>경계 라우트 앞단</strong>에서 전역적으로 <code class="language-plaintext highlighter-rouge">Camel*Uri</code>류 헤더를 제거하는 Processor를 만들어서 공통으로 붙인다.</li>
  <li>서비스별로 인터넷 facing consumer(<code class="language-plaintext highlighter-rouge">platform-http</code>, <code class="language-plaintext highlighter-rouge">servlet</code>, <code class="language-plaintext highlighter-rouge">netty-http</code> consumer 등) 라우트에 “경계 템플릿”을 적용한다.</li>
</ul>

<p>어느 쪽이든 “Camel 내부 control header는 외부 입력에 섞이면 안 된다”를 코드 레벨 규칙으로 만드는 게 목적입니다.</p>

<h4 id="업그레이드-후-변화-per-message-placeholder-resolution-제거">업그레이드 후 변화: per-message placeholder resolution 제거</h4>

<p>4.23 업그레이드 가이드에도 동일 변경이 명시돼 있습니다. HTTP producer는 더 이상 message-supplied override header에서 <code class="language-plaintext highlighter-rouge">{{...}}</code> placeholder를 풀지 않고, 라우트에 적힌 endpoint URI의 placeholder만 기존처럼 해석합니다.<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup></p>

<p>이 변화는 보안상 이득이 크지만, 일부 레거시 라우트가 “헤더에 placeholder를 넣어 동적 확장”을 했을 수 있습니다. 그런 라우트는 업그레이드 직후에 “기능이 바뀐 것처럼” 보일 가능성이 높고, 테스트 포인트가 됩니다.</p>

<h3 id="redis-deserialization-cve-2026-78165">Redis deserialization: CVE-2026-78165</h3>

<p>CVE-2026-78165는 “default가 unsafe였다”는 케이스입니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">camel-spring-redis</code>는 serializer 옵션이 없으면 <code class="language-plaintext highlighter-rouge">JdkSerializationRedisSerializer</code>를 기본으로 만들었고</li>
  <li>이 serializer가 <code class="language-plaintext highlighter-rouge">ObjectInputFilter</code> 없이 deserialize 했으며</li>
  <li>특히 consumer는 subscribed channel에 publish된 메시지를 <strong>Exchange 생성 이전</strong>에 deserialize 했기 때문에, 라우트에서 검증할 기회가 없었다고 설명합니다.<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
</ul>

<h4 id="즉시-차단업그레이드-전-serializer를-java-serialization이-아닌-것으로-고정">즉시 차단(업그레이드 전): serializer를 Java serialization이 아닌 것으로 고정</h4>

<p>advisory는 업그레이드가 최선이지만, 당장 못 하면 두 가지를 제시합니다.</p>

<ul>
  <li>JVM-wide <code class="language-plaintext highlighter-rouge">-Djdk.serialFilter=...</code> 설정(해당 경로가 <code class="language-plaintext highlighter-rouge">ObjectInputStream</code>을 타기 때문에 적용됨)</li>
  <li>endpoint 옵션 <code class="language-plaintext highlighter-rouge">serializer</code>를 Java serialization이 아닌 serializer로 지정<sup id="fnref:3:2" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
</ul>

<p>운영에서는 두 번째가 더 선명합니다. Redis pub/sub payload를 굳이 Java serialization으로 주고받을 이유가 거의 없습니다.</p>

<p>업그레이드 전/후를 가리지 않고 내가 선호하는 형태는 “String/JSON/bytes로 고정”입니다.</p>

<p>예시(개념을 보여주는 수준이 아니라, 실제로 돌아가는 구성으로 잡는 형태):</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Spring Boot에서 RedisSerializer 빈을 등록하고</span>
<span class="nd">@Bean</span>
<span class="kd">public</span> <span class="n">org</span><span class="o">.</span><span class="na">springframework</span><span class="o">.</span><span class="na">data</span><span class="o">.</span><span class="na">redis</span><span class="o">.</span><span class="na">serializer</span><span class="o">.</span><span class="na">RedisSerializer</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="nf">safeStringSerializer</span><span class="o">()</span> <span class="o">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="n">org</span><span class="o">.</span><span class="na">springframework</span><span class="o">.</span><span class="na">data</span><span class="o">.</span><span class="na">redis</span><span class="o">.</span><span class="na">serializer</span><span class="o">.</span><span class="na">StringRedisSerializer</span><span class="o">();</span>
<span class="o">}</span>

<span class="c1">// Camel URI에서 serializer 옵션으로 지정</span>
<span class="n">from</span><span class="o">(</span><span class="s">"spring-redis://redis.internal:6379"</span>
    <span class="o">+</span> <span class="s">"?command=SUBSCRIBE"</span>
    <span class="o">+</span> <span class="s">"&amp;channels=events"</span>
    <span class="o">+</span> <span class="s">"&amp;serializer=#safeStringSerializer"</span><span class="o">)</span>
  <span class="o">.</span><span class="na">process</span><span class="o">(</span><span class="n">e</span> <span class="o">-&gt;</span> <span class="o">{</span>
      <span class="c1">// 이 시점에는 body가 String으로 들어온다</span>
      <span class="nc">String</span> <span class="n">payload</span> <span class="o">=</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">().</span><span class="na">getBody</span><span class="o">(</span><span class="nc">String</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
      <span class="c1">// JSON이면 여기서 Jackson으로 파싱</span>
  <span class="o">})</span>
  <span class="o">.</span><span class="na">to</span><span class="o">(</span><span class="s">"direct:handleEvent"</span><span class="o">);</span>
</code></pre></div></div>

<p>이 방식은 “취약점 회피”뿐 아니라 운영 사고를 줄입니다.</p>

<ul>
  <li>Java serialization payload는 언어/버전 결합도가 너무 높습니다.</li>
  <li>Redis는 멀티 프로듀서/멀티 컨슈머 구조가 흔하고, 채널 publish 권한이 넓게 열리는 순간이 자주 생깁니다.</li>
</ul>

<h4 id="업그레이드-후-변화-default-serializer가-deserialization-filter를-적용">업그레이드 후 변화: default serializer가 deserialization filter를 적용</h4>

<p>4.23 업그레이드 가이드는 <code class="language-plaintext highlighter-rouge">camel-spring-redis</code> 기본 serializer가 JEP-290 기반 <code class="language-plaintext highlighter-rouge">ObjectInputFilter</code>를 적용하고, <code class="language-plaintext highlighter-rouge">deserializationFilter</code> 옵션으로 allow-list를 확장할 수 있다고 명시합니다. 또한 serializer를 커스텀으로 바꾸면 Camel이 읽는 과정을 제어하지 못하니 필터가 우회된다고도 적혀 있습니다.<sup id="fnref:8:1" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup></p>

<p>여기서 운영 판단이 갈립니다.</p>

<ul>
  <li>Redis payload가 애초에 Java serialization이면, 업그레이드 후 필터로 인해 “기존에 통과하던 타입이 막혀” 장애가 날 수 있습니다.</li>
  <li>반대로 payload를 String/JSON로 바꾸면 필터 이슈 자체가 사라지고, 장기적으로 더 건강합니다.</li>
</ul>

<p>내 경우는 “취약점 대응”을 계기로 payload 타입을 바꾸는 쪽을 택하는 편입니다. 업그레이드로 얻는 안전장치가 ‘추가’라면, 운영 설계 자체는 더 단순해지게 만드는 게 맞습니다.</p>

<h3 id="hazelcast-deserialization-cve-2026-78162">Hazelcast deserialization: CVE-2026-78162</h3>

<p>CVE-2026-78162는 “이전 CVE의 incomplete fix” 성격이고, <code class="language-plaintext highlighter-rouge">camel-hazelcast</code>가 자체 생성하는 인스턴스의 특정 구성 경로에서 JavaSerializationFilterConfig가 적용되지 않아 RCE sink가 남았다는 내용입니다. Hazelcast 자체도 default로 auth/TLS가 꺼져 있다는 설명이 붙어 있습니다.<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup></p>

<h4 id="즉시-차단업그레이드-전-hazelcastinstanceconfig를-명시적으로-주입">즉시 차단(업그레이드 전): HazelcastInstance/Config를 명시적으로 주입</h4>

<p>advisory의 미티게이션은 꽤 실용적입니다.</p>

<ul>
  <li>Camel이 “자체 생성하는” 경로가 문제이므로, 사용자가 <strong>명시적 Hazelcast config/instance를 주면 Camel은 덮어쓰지 않는다</strong></li>
  <li>대안으로 JVM-wide <code class="language-plaintext highlighter-rouge">-Djdk.serialFilter=...</code>를 줄 수 있다<sup id="fnref:9:1" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup></li>
</ul>

<p>운영에서 내가 잡는 기본은 다음입니다.</p>

<ul>
  <li>Hazelcast를 외부 클러스터로 쓸 거면 auth/TLS를 먼저 잡고,</li>
  <li>Camel 쪽에서는 “자동 생성”을 최대한 피한다.</li>
</ul>

<h3 id="remote-file-consumer-path-traversal-cve-2026-7998379986">remote file consumer path traversal: CVE-2026-79983~79986</h3>

<p><code class="language-plaintext highlighter-rouge">camel-ftp</code> advisory(CVE-2026-79983)는 설명이 매우 구체적입니다.</p>

<ul>
  <li>remote-file consumer가 directory listing의 “파일명”을 신뢰하고 경로를 이어붙였고</li>
  <li>lexical normalization/containment check가 없어서</li>
  <li>listing entry에 traversal segment가 섞이면 poll 디렉터리 밖으로 탈출한 remote path로 retrieve/delete/rename을 수행할 수 있었다</li>
  <li>FTP control channel은 무결성 보호가 없어서, on-path 공격자가 LIST 응답을 변조할 수 있다고도 명시합니다.<sup id="fnref:4:1" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<p><code class="language-plaintext highlighter-rouge">camel-smb</code>, <code class="language-plaintext highlighter-rouge">camel-azure-files</code>도 기본 구조는 동일하며, 결국 “listing name은 라우트가 제어하는 데이터가 아니다”가 핵심입니다.<sup id="fnref:10" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">10</a></sup></p>

<p>이 부류는 설정만으로 완벽히 막기 어렵습니다. listing 자체가 공격 데이터가 될 수 있고, Camel이 그 값을 operand로 삼아 서버 측 파일 연산을 해버리기 때문입니다.</p>

<p>그래서 운영 triage에서는 아래처럼 단순화합니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ftp/sftp/smb/azure-files</code> consumer를 쓰는 서비스는 “가급적 최우선 업그레이드”로 올린다.</li>
  <li>업그레이드 전까지는
    <ul>
      <li>remote 서버 신뢰도를 재점검하고(멀티테넌트/파트너 제공/네트워크 경유)</li>
      <li>가능하면 “읽기 전용 계정 + 최소 권한 디렉터리”로 격리하고</li>
      <li><code class="language-plaintext highlighter-rouge">delete=true</code>, <code class="language-plaintext highlighter-rouge">move=</code> 같은 파괴적 옵션을 꺼서 피해 반경을 줄인다.</li>
    </ul>
  </li>
</ul>

<p>이건 기술적으로는 소극적이지만, 실제 운영에서는 업그레이드가 늦어질 때 피해 반경을 줄이는 데 의미가 있습니다.</p>

<h3 id="oauth-fail-open--netty-http-constraint-bypass-cve-2026-80312-cve-2026-80343">OAuth fail-open / netty-http constraint bypass: CVE-2026-80312, CVE-2026-80343</h3>

<p>두 CVE는 종류가 다르지만 운영 대응 방식은 닮았습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">camel-oauth</code>는 인증 실패 시 exchange를 멈추지 않고 라우트에 제어권을 돌려줘서 보호 구간이 실행되는 fail-open이었다고 설명합니다. 업그레이드 전에는 OAuth processor 뒤에 명시적 authorization check를 넣고 실패 시 라우트를 멈추라고 안내합니다.<sup id="fnref:5:1" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li><code class="language-plaintext highlighter-rouge">camel-netty-http</code>는 dispatch는 case-insensitive로 가는데 constraint match는 case-sensitive로 가서, path segment의 case를 바꾸면 constraint가 빠지고 라우트가 도달했다고 설명합니다. 업그레이드 전 workaround로는 “Camel consumer 앞단(리버스 프록시 등)에서 path normalization 후 authorization”을 둡니다.<sup id="fnref:11" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup></li>
</ul>

<p>이 둘은 “Camel이 뭔가 잘못했네”로 끝나지 않습니다. 보안 경계를 어디에 두는지에 따라 피해가 달라집니다.</p>

<ul>
  <li>Camel 자체의 인증/인가 컴포넌트를 신뢰하는 아키텍처면 Camel의 fail-open이 곧 서비스 전체 fail-open이 됩니다.</li>
  <li>프록시 레이어(Envoy/nginx/ALB/API Gateway)에서 이미 authz/authn을 끝내고 Camel은 내부용이면, Camel 쪽 버그의 직접 영향은 줄어듭니다.</li>
</ul>

<p>그래서 나는 이번 유형의 CVE를 “패치 이벤트”로만 보지 않고, 아키텍처 원칙을 다시 확인하는 계기로 씁니다. 예전에 <a href="https://daewooki.github.io/posts/apache-httpd-2469-proxy-chain-security/">Apache httpd 2.4.69: HTTP/2 패치가 프록시 체인에 미치는 영향</a>을 정리할 때도 같은 결론으로 갔습니다. 프록시 체인이 복잡해질수록, 경계의 책임을 명확히 하고 normalization을 한 지점에서 끝내야 합니다.</p>

<h3 id="dynamic-router-control-predicate-code-execution-cve-2026-83531">dynamic-router-control predicate code execution: CVE-2026-83531</h3>

<p>CVE-2026-83531은 전제조건이 비교적 명확합니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">dynamic-router-control:</code> 엔드포인트로 들어가는 control message가 predicate expression과 expression language를 같이 들고 오고</li>
  <li>Camel이 allow-list/opt-in 없이 그 language를 resolve해 predicate를 컴파일해 등록했기 때문에</li>
  <li>외부 transport에서 control endpoint로 메시지가 흘러들 수 있으면 프로세스 내 code execution으로 이어질 수 있다는 내용입니다.<sup id="fnref:6:1" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
</ul>

<p>이 CVE는 “우리 서비스는 dynamic-router 안 쓰는데?”로 끝나는 게 아니라, 라우트가 커지면서 다음 패턴이 나오기 쉬운지 점검하게 만듭니다.</p>

<ul>
  <li>내부/외부 이벤트 버스에서 control message를 받아 dynamic subscription을 제어</li>
  <li>멀티테넌트 환경에서 특정 테넌트가 구독 규칙을 업데이트</li>
</ul>

<p>업그레이드 후에는 message에서 predicate/language를 받는 동작이 기본으로 거부되고, <code class="language-plaintext highlighter-rouge">allowPredicateFromMessage</code> 옵션을 켰을 때만 허용된다고 advisory에 적혀 있습니다. 즉, “기능을 계속 쓰려면 명시적으로 위험을 승인”하게 바뀝니다.<sup id="fnref:6:2" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></p>

<p>업그레이드 전에는 더 단순합니다.</p>

<ul>
  <li>untrusted/shared transport에서 <code class="language-plaintext highlighter-rouge">dynamic-router-control:</code>로 메시지를 포워딩하지 않는다.</li>
  <li>어쩔 수 없다면, 해당 라우트에서 predicate/language 관련 헤더/필드를 제거하고, 구독 규칙은 라우트가 정한 고정값으로만 세팅한다.<sup id="fnref:6:3" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
</ul>

<h2 id="lts-라인-유지418x422x-vs-4230-이동-의사결정을-만드는-기준">LTS 라인 유지(4.18.x/4.22.x) vs 4.23.0 이동: 의사결정을 만드는 기준</h2>

<p>Camel 공지는 업그레이드 표를 아주 명확하게 줍니다.</p>

<ul>
  <li>4.22.x(LTS) → 4.22.1</li>
  <li>4.18.x(LTS) → 4.18.5</li>
  <li>그 외 → 4.23.0(또는 LTS 라인으로 이동)<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>그리고 릴리스 노트는 4.23.0을 LTS 릴리스라고 명시합니다.<sup id="fnref:12" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup></p>

<p>여기서 조직 내 합의가 갈리는 지점은 이겁니다.</p>

<ul>
  <li>“우리는 당장 패치만 하고 싶다(변경 최소화)”</li>
  <li>“어차피 갈아엎는 김에 새 LTS로 간다(장기 유지)”</li>
</ul>

<p>나는 이 결정을 감정/취향으로 안 하고, 아래 질문으로 쪼갭니다.</p>

<h3 id="1-이번-38건이-설정-기본값-변화를-동반하는가">1) 이번 38건이 ‘설정 기본값 변화’를 동반하는가</h3>

<p>Camel 공지 자체가 “일부 수정은 default behavior를 바꾼다”라고 경고합니다.<sup id="fnref:1:4" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>실제로 4.23 업그레이드 가이드에 “보안성 강화를 위한 기본 동작 변화”가 들어 있습니다.</p>

<ul>
  <li>HTTP producer override header에서 placeholder를 더 이상 풀지 않음(CVE-2026-78164 연관)<sup id="fnref:8:2" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup></li>
  <li><code class="language-plaintext highlighter-rouge">camel-spring-redis</code> 기본 serializer가 deserialization filter를 적용하고, allow-list를 <code class="language-plaintext highlighter-rouge">deserializationFilter</code>로 확장<sup id="fnref:8:3" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup></li>
</ul>

<p>이런 변화는 보안에는 이득이지만 기능 호환성에는 비용이 됩니다. 업그레이드 테스트 범위를 줄이려면 “변경이 적은 라인에서 패치만”이 유리합니다.</p>

<h3 id="2-서비스가-어느-라인을-이미-쓰고-있는가가-비용을-지배한다">2) 서비스가 ‘어느 라인을 이미 쓰고 있는가’가 비용을 지배한다</h3>

<ul>
  <li>이미 4.22.x를 쓰는 서비스는 4.22.1로 가는 게 가장 저렴합니다.</li>
  <li>이미 4.18.x를 쓰는 서비스는 4.18.5가 가장 저렴합니다.</li>
</ul>

<p>이건 기술적으로 당연한데, 조직에서는 “한 번에 통일”이라는 욕망이 종종 튀어나옵니다. 통일은 장점도 있지만, 보안 패치 사건에서 통일을 1순위로 두면 테스트/릴리즈 병목이 더 커져 전체 대응이 느려지는 경우가 많았습니다.</p>

<h3 id="3-414x-이하가-남아-있으면-결론은-빠르다">3) 4.14.x 이하가 남아 있으면 결론은 빠르다</h3>

<p>Camel은 4.14.x가 EOL이고 이번 수정 대부분이 4.14.x에도 해당한다고까지 말했습니다.<sup id="fnref:1:5" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이건 사실상 “남아 있으면 빨리 탈출”입니다. LTS든 뭐든, 패치가 더 이상 안 나오면 취약점 대응 비용이 계속 누적됩니다.</p>

<h3 id="4-java-버전런타임-정책을-같이-묶어야-한다">4) Java 버전/런타임 정책을 같이 묶어야 한다</h3>

<p>4.23.0 릴리스 노트는 Java 17, 21, 25를 지원한다고 씁니다.<sup id="fnref:12:1" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup></p>

<p>조직에서 Java 업그레이드가 병행되는 구간이면, Camel 업그레이드 라인 선택이 Java 정책과 충돌하지 않는지 확인해야 합니다. “보안 패치”만 보다가, 런타임 정책이 발목을 잡는 게 흔한 비용입니다.</p>

<h2 id="업그레이드차단-전략을-컴포넌트별-영향도로-정리하는-템플릿">업그레이드/차단 전략을 ‘컴포넌트별 영향도’로 정리하는 템플릿</h2>

<p>내가 내부적으로 정리할 때는 아래 6열로 끝냅니다.</p>

<p>1) Service
2) Entry trust boundary(인터넷/파트너/내부)
3) Camel component(artifact + scheme)
4) CVE list(Severity)
5) Immediate mitigation(차단/설정 안전화)
6) Upgrade path(4.18.5/4.22.1/4.23.0) + Test scope</p>

<p>여기서 “Immediate mitigation”은 업그레이드 전용이 아닙니다. 업그레이드 이후에도 남겨야 하는 공통 방어선이 많습니다.</p>

<ul>
  <li>control header는 신뢰 경계에서 제거(이번 CVE 묶음이 아니어도 계속 유효)</li>
  <li>deserialization payload 타입을 JSON/String/bytes로 정규화(필터에 기대지 않게)</li>
  <li>authz/authn 결정을 Camel consumer 안이 아니라 앞단 경계에서 끝내기</li>
</ul>

<p>이런 걸 먼저 깔아두면, 다음에 또 “다건 CVE 묶음”이 와도 영향도 산정이 빨라집니다.</p>

<h2 id="한-번에-소화할-때-테스트-범위를-줄이는-방법">‘한 번에 소화’할 때 테스트 범위를 줄이는 방법</h2>

<p>테스트 범위 설계는 컴포넌트별로 갈라야 합니다. 이번 묶음처럼 취약점 유형이 서로 다른데도 “통합 테스트 한 세트”로 뭉치면 시간이 폭발합니다.</p>

<p>내가 쓰는 방식은 다음입니다.</p>

<h3 id="1-취약점-유형별로-회귀-테스트를-만든다">1) 취약점 유형별로 회귀 테스트를 만든다</h3>

<ul>
  <li>placeholder/header 관련: 경계에서 <code class="language-plaintext highlighter-rouge">CamelHttpUri</code>류 제거가 항상 적용되는지, override header가 남아도 placeholder가 확장되지 않는지(업그레이드 후) 확인</li>
  <li>deserialization 관련: Redis/Hazelcast/JGroups 등에서 Java serialization이 들어오면 필터로 차단되는지, 혹은 애초에 serializer를 바꿔서 Java serialization이 안 들어오게 했는지 확인</li>
  <li>path traversal 관련: remote-file consumer를 쓰는 서비스는 “업그레이드 전에는 격리/권한 최소화”가 적용됐는지, 업그레이드 후에는 취약점 경로가 차단됐는지(가능하면 재현 테스트)</li>
  <li>auth bypass 관련: 인증 실패 케이스가 실제로 라우트를 중단시키는지(특히 <code class="language-plaintext highlighter-rouge">camel-oauth</code>는 advisory가 업그레이드 전에도 명시적 check를 권합니다)<sup id="fnref:5:2" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
</ul>

<h3 id="2-컴포넌트별로-테스트-우선순위를-분리한다">2) 컴포넌트별로 테스트 우선순위를 분리한다</h3>

<p>대체로 우선순위는 아래처럼 잡는 편입니다.</p>

<ul>
  <li>인터넷-facing HTTP consumer + <code class="language-plaintext highlighter-rouge">camel-netty-http</code>/<code class="language-plaintext highlighter-rouge">camel-oauth</code> 같이 인증/제약이 붙는 컴포넌트</li>
  <li>메시지 기반 deserialization sink(<code class="language-plaintext highlighter-rouge">camel-spring-redis</code>, <code class="language-plaintext highlighter-rouge">camel-hazelcast</code> 등)</li>
  <li>파일/스토리지 consumer(<code class="language-plaintext highlighter-rouge">camel-ftp</code>, <code class="language-plaintext highlighter-rouge">camel-smb</code>, <code class="language-plaintext highlighter-rouge">camel-azure-files</code> 등)</li>
</ul>

<h3 id="3-sbom을-패치-증거로-남긴다">3) SBOM을 ‘패치 증거’로 남긴다</h3>

<p>Camel은 4.0.3부터 모든 릴리스에 PGP-signed CycloneDX SBOM을 제공한다고 공식 문서에 적어두었습니다.<sup id="fnref:7:1" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></p>

<p>이건 “Camel을 올렸습니다”보다 더 강한 증거가 됩니다.</p>

<ul>
  <li>릴리스 버전이 바뀌었고</li>
  <li>그 버전의 dependency 구성이 무엇인지</li>
  <li>공급망 관점에서 무엇이 들어왔는지</li>
</ul>

<p>CVE 대응 보고/감사에서 특히 유용합니다. “업그레이드 완료”를 증명하기 위한 자료를, 보안팀/플랫폼팀/개발팀이 같은 포맷으로 공유할 수 있습니다.</p>

<h2 id="반론과-회의론-우린-그-컴포넌트-안-써서-괜찮다가-위험해지는-지점">반론과 회의론: ‘우린 그 컴포넌트 안 써서 괜찮다’가 위험해지는 지점</h2>

<p>이 말이 틀린 건 아닙니다. 실제로 컴포넌트 기반 인벤토리의 목적은 “안 써도 되는 업그레이드”를 줄이는 것입니다.</p>

<p>그런데 다음 조건이 겹치면 위험해집니다.</p>

<ul>
  <li>build에는 포함돼 있다(누군가 starter를 추가했다)</li>
  <li>라우트는 동적이다(toD/recipientList 등)</li>
  <li>메시지는 여러 시스템을 거치며 헤더/바디가 섞인다</li>
</ul>

<p>특히 CVE-2026-78164처럼 “헤더로 URI override + placeholder resolution”이 엮이면, 어떤 라우트는 정적으로 안전해 보여도 경계가 흐려지는 순간 바로 노출됩니다.<sup id="fnref:2:6" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>또한 remote-file consumer 취약점(CVE-2026-79983 등)은 “우린 상대 서버를 신뢰한다”가 전제입니다. FTP는 control channel 무결성이 없다는 사실이 advisory 자체에 적혀 있고, on-path 공격 가능성까지 언급합니다.<sup id="fnref:4:2" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<p>즉, “컴포넌트 안 쓴다”는 말은 (1) artifact가 없고, (2) 라우트에 스킴이 없고, (3) 동적으로도 생성되지 않으며, (4) 경계에서 control header를 제거한다는 게 같이 성립해야 안전합니다.</p>

<h2 id="정리">정리</h2>

<p>2026-10-16 Camel의 38건 advisory 일괄 공지는 “패치를 하자”에서 끝나지 않고, 조직이 업그레이드 우선순위 산정과 테스트 범위 설계를 다시 하게 만드는 이벤트였습니다.<sup id="fnref:1:6" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이때 의사결정의 단위는 Camel이 아니라 컴포넌트입니다.</p>

<ul>
  <li>서비스별 컴포넌트 인벤토리를 의존성과 라우트 양쪽에서 만든다.</li>
  <li>CVE를 노출 경로 타입으로 다시 분류해, 업그레이드 전에도 가능한 차단/안전화 조치를 먼저 적용한다.</li>
  <li>업그레이드는 4.18.x/4.22.x LTS 패치로 빠르게 닫을지, 4.23.0 LTS로 장기 유지 전략을 가져갈지 “기본값 변화와 테스트 비용”으로 판단한다.</li>
</ul>

<p>결국 이 방식이 다건 CVE 묶음을 반복적으로 맞아도 대응 속도와 품질을 동시에 유지하는 쪽으로 수렴합니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://camel.apache.org/blog/2026/10/camel-security-advisories-4.23.0/">Apache Camel 보안 권고 38건 공지(4.18.5/4.22.1/4.23.0)</a></li>
  <li><a href="https://camel.apache.org/manual/camel-4x-upgrade-guide-4_23.html">Apache Camel 4.23 업그레이드 가이드</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-78164.html">CVE-2026-78164 advisory: HTTP producer override header에서 placeholder 해석</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-78165.html">CVE-2026-78165 advisory: camel-spring-redis deserialization</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-78162.html">CVE-2026-78162 advisory: camel-hazelcast deserialization filter 누락</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-79983.html">CVE-2026-79983 advisory: camel-ftp path traversal</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-79984.html">CVE-2026-79984 advisory: camel-mina-sftp path traversal</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-79985.html">CVE-2026-79985 advisory: camel-smb path traversal</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-79986.html">CVE-2026-79986 advisory: camel-azure-files path traversal</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-80312.html">CVE-2026-80312 advisory: camel-oauth authentication bypass</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-80343.html">CVE-2026-80343 advisory: camel-netty-http security constraint bypass</a></li>
  <li><a href="https://camel.apache.org/security/CVE-2026-83531.html">CVE-2026-83531 advisory: dynamic-router-control predicate code execution</a></li>
  <li><a href="https://camel.apache.org/security/">Apache Camel Security 페이지(보안 모델, SBOM, advisory 목록)</a></li>
  <li><a href="https://camel.apache.org/manual/security-model.html">Apache Camel Security Model</a></li>
  <li><a href="https://camel.apache.org/releases/release-4.23.0/">Apache Camel 4.23.0 릴리스 노트</a></li>
  <li><a href="https://camel.apache.org/blog/2026/06/camel-sbom-supply-chain/">Apache Camel SBOM 관련 글</a></li>
  <li><a href="https://news.apache.org/foundation/entry/security-scanning-at-foundation-scale">ASF: Foundation 규모 보안 스캔(Project Glasswing)</a></li>
  <li><a href="https://daewooki.github.io/posts/apache-httpd-2469-proxy-chain-security/">Apache httpd 2.4.69: HTTP/2 패치가 프록시 체인에 미치는 영향</a></li>
  <li><a href="https://daewooki.github.io/posts/mod-pagespeed-22-nginx-worker-ops/">mod_pagespeed 2.2 운영: nginx에서 optimizer worker 붙이고 안전하게 업데이트하기</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://camel.apache.org/blog/2026/10/camel-security-advisories-4.23.0/">https://camel.apache.org/blog/2026/10/camel-security-advisories-4.23.0/</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:1:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:1:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:1:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-78164.html">https://camel.apache.org/security/CVE-2026-78164.html</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:2:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:2:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:2:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:2:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:2:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-78165.html">https://camel.apache.org/security/CVE-2026-78165.html</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:3:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-79983.html">https://camel.apache.org/security/CVE-2026-79983.html</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:4:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:4:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-80312.html">https://camel.apache.org/security/CVE-2026-80312.html</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:5:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:5:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-83531.html">https://camel.apache.org/security/CVE-2026-83531.html</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:6:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:6:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:6:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/">https://camel.apache.org/security/</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:7:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://camel.apache.org/manual/camel-4x-upgrade-guide-4_23.html">https://camel.apache.org/manual/camel-4x-upgrade-guide-4_23.html</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:8:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:8:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:8:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-78162.html">https://camel.apache.org/security/CVE-2026-78162.html</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:9:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:10" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-79985.html">https://camel.apache.org/security/CVE-2026-79985.html</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:11" role="doc-endnote">
      <p><a href="https://camel.apache.org/security/CVE-2026-80343.html">https://camel.apache.org/security/CVE-2026-80343.html</a> <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:12" role="doc-endnote">
      <p><a href="https://camel.apache.org/releases/release-4.23.0/">https://camel.apache.org/releases/release-4.23.0/</a> <a href="#fnref:12" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:12:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="Backend" /><category term="Apache Camel" /><category term="apache-camel" /><category term="security-advisory" /><category term="cve-triage" /><category term="dependency-inventory" /><category term="upgrade-strategy" /><category term="lts" /><summary type="html"><![CDATA[Camel 보안 권고가 한꺼번에 쏟아질 때, 서비스별 사용 컴포넌트를 먼저 확정하고 설정 안전화와 LTS/신규 라인 업그레이드를 분리해 의사결정을 만든다.]]></summary></entry><entry><title type="html">2026-10-26 (월) 개발일지</title><link href="https://joyuno.github.io/blog/2026/10/26/dev-blog/" rel="alternate" type="text/html" title="2026-10-26 (월) 개발일지" /><published>2026-10-26T00:00:00+09:00</published><updated>2026-10-26T00:00:00+09:00</updated><id>https://joyuno.github.io/blog/2026/10/26/dev-blog</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/26/dev-blog/"><![CDATA[<p>오늘은 게임 서버 연결 끊김과 Lepons Cockpit 설치·연결 장애라는 두 가지를 파고들었다. 겉보기엔 전혀 다른 문제였지만, 둘 다 <strong>앱이나 시스템이 조용히 실패하는 지점</strong>을 찾아내는 과정이었다. 하나는 Docker 설정 하나가 10일간 서버를 죽음으로 몰아넣었고, 다른 하나는 앱이 사용자를 올바른 경로로 안내하지 못해 발생한 권한 벽이었다.</p>

<h2 id="게임-서버-10일-다운타임--mac-재부팅과-docker-auto-start-의-함정">게임 서버 10일 다운타임 — Mac 재부팅과 Docker auto-start 의 함정</h2>

<p>게임 클라이언트가 서버 연결을 계속 끊는다는 보고가 들어왔다. <code class="language-plaintext highlighter-rouge">docker ps</code>를 실행하자 데몬 자체가 응답하지 않았다. 서버는 개발용 Mac이다.</p>

<p>첫 가설은 코드 회귀였다. 하지만 <code class="language-plaintext highlighter-rouge">app/</code> 디렉토리의 마지막 커밋은 9/22. 코드는 1달 넘게 손대지 않았다. 그럼 Docker Desktop이 크래시 났거나 Mac이 절전 모드에 들어간 걸로 의심했다.</p>

<p><code class="language-plaintext highlighter-rouge">ps aux | grep docker</code>로 프로세스를 확인했다. <code class="language-plaintext highlighter-rouge">com.docker.vmnetd</code>(권한 헬퍼)는 살아 있지만 핵심 엔진 프로세스가 보이지 않았다. Docker 로그를 뒤졌다. 마지막 로그 기록이 <strong>10/16 02:28</strong>. Mac 시스템 로그(<code class="language-plaintext highlighter-rouge">log show --last boot</code>)를 확인하니 <strong>10/16 02:09</strong>에 재부팅 로그가 찍혀 있었다. 재부팅 이후 Docker가 살아난 흔적이 전혀 없었다.</p>

<blockquote>
  <p>결정적 증거는 Docker Desktop 설정에서 찾았다. <strong><code class="language-plaintext highlighter-rouge">auto start: false</code></strong> 옵션이 체크되어 있었다. 재부팅 후 Docker가 자동으로 시작되지 않았고, 그 상태로 10일이 지난 거다.</p>
</blockquote>

<p>Docker Desktop을 수동으로 실행하고 <code class="language-plaintext highlighter-rouge">docker compose up -d</code>로 스택을 복구했다. <code class="language-plaintext highlighter-rouge">api</code> 컨테이너는 8000번 포트에 정상 바인딩, <code class="language-plaintext highlighter-rouge">app-db</code>는 healthy. Alembic 헤드도 <code class="language-plaintext highlighter-rouge">0009_blank_submission_mode</code>로 밀린 마이그레이션이 없었다. <code class="language-plaintext highlighter-rouge">/health/live</code>와 <code class="language-plaintext highlighter-rouge">/health/ready</code> 모두 <code class="language-plaintext highlighter-rouge">{"status":"ok"}</code>와 <code class="language-plaintext highlighter-rouge">{"status":"ready"}</code>를 반환했다.</p>

<p><strong>검증</strong>: 클라이언트 재연결 후 끊김 보고 중단. <code class="language-plaintext highlighter-rouge">auto start</code>를 <code class="language-plaintext highlighter-rouge">true</code>로 변경하고 마무리. 재부팅 원인은 아직 모르지만, 문제의 본질은 ‘OS 업데이트’가 아니라 ‘Docker가 다시 안 켜지게 설정된 상태’였다는 점이 교훈이다.</p>

<h2 id="lepons-cockpit--설치연결-첫-단계에서-막히는-권한-이슈-14건">Lepons Cockpit — 설치·연결 첫 단계에서 막히는 권한 이슈 14건</h2>

<p>비개발자 사용자로부터 Windows SmartScreen 차단, macOS 격리 경고, 호스트 키 변경 시 진행 불가 등 설치와 연결 첫 단계에서 막혔다는 피드백이 여러 건 들어왔다.</p>

<p>처음엔 각 케이스를 개별 버그로 접근했다. SmartScreen은 서명 문제니까 어쩔 수 없고, macOS 격리는 개발자만 겪는 문제라고 생각했다. 하지만 사용자 패턴을 분석하니 공통점이 보였다. <strong>앱이 사용자에게 막다른 길을 안내하고 있었다.</strong> 예를 들어 호스트 키가 바뀌었을 때 ‘의도적으로 거부한 게 아니라면 연결하지 마세요’라는 메시지만 띄우고, 사용자가 직접 키를 갱신할 방법을 전혀 안내하지 않았다.</p>

<p>macOS 케이스를 공식 문서로 확인했다. <code class="language-plaintext highlighter-rouge">app.isInApplicationsFolder()</code>가 <code class="language-plaintext highlighter-rouge">false</code>인 상태로 실행되면 업데이트 실패와 이상 동작이 발생한다. 지금 앱은 이 상태를 무시하고 넘어가고 있었다. Windows SmartScreen은 앱이 회피할 수 없지만, ‘추가 정보 → 실행’ 경로를 앱 안에서 안내하면 사용자가 막히지 않는다.</p>

<blockquote>
  <p>14개의 권한·환경 이슈를 <code class="language-plaintext highlighter-rouge">2026-10-26-permissions-audit.md</code>에 표로 정리했다. 이 중 12개는 앱이 직접 처리할 수 있었고, 2개는 시스템 설정 자체라 가이드로만 제공하기로 했다.</p>
</blockquote>

<p><strong>바뀐 점</strong></p>
<ul>
  <li><strong>macOS</strong>: <code class="language-plaintext highlighter-rouge">isInApplicationsFolder()</code>가 false면 사용자에게 묻고 자동으로 Applications 폴더로 이동(<code class="language-plaintext highlighter-rouge">moveToApplicationsFolder()</code>)</li>
  <li><strong>Windows</strong>: SmartScreen 경고 화면에서 ‘추가 정보 → 실행’ 경로를 안내하는 인라인 도움말 추가</li>
  <li><strong>호스트 키 변경</strong>: 키 변경 시 사용자가 직접 수락/갱신할 수 있는 UI 흐름 추가</li>
  <li><strong>원격 에이전트 미설치</strong>: 감지 시 사용자에게 설치 팝업을 띄우는 로직으로 변경</li>
</ul>

<p><strong>검증</strong>: 수정 12건을 <code class="language-plaintext highlighter-rouge">integrate-perm-fixes</code> 브랜치에 합쳐 <strong>1.7.2</strong> 테스트 빌드를 만들었다. <code class="language-plaintext highlighter-rouge">npm test</code>는 465개 중 464개 통과, 실패 0. 남은 2건(방화벽 포트 허용, Tailscale 인증서)은 가이드 문서로 정리했다.</p>

<h3 id="오늘의-한줄">오늘의 한줄</h3>
<p>조용히 실패하는 시스템은 코드 오류보다 설정 하나나 안내 부재에서 더 자주 발생한다.</p>]]></content><author><name>joyuno</name></author><category term="dev-blog" /><category term="dev-blog" /><category term="til" /><summary type="html"><![CDATA[오늘은 게임 서버 연결 끊김과 Lepons Cockpit 설치·연결 장애라는 두 가지를 파고들었다. 겉보기엔 전혀 다른 문제였지만, 둘 다 앱이나 시스템이 조용히 실패하는 지점을 찾아내는 과정이었다. 하나는 Docker 설정 하나가 10일간 서버를 죽음으로 몰아넣었고, 다른 하나는 앱이 사용자를 올바른 경로로 안내하지 못해 발생한 권한 벽이었다.]]></summary></entry><entry><title type="html">Rundeck 6.3.0: Conditional Logic와 Docker 신뢰 체인 운영</title><link href="https://joyuno.github.io/blog/2026/10/25/rundeck-630-conditional-logic-trust-chain/" rel="alternate" type="text/html" title="Rundeck 6.3.0: Conditional Logic와 Docker 신뢰 체인 운영" /><published>2026-10-25T12:44:40+09:00</published><updated>2026-10-25T12:44:40+09:00</updated><id>https://joyuno.github.io/blog/2026/10/25/rundeck-630-conditional-logic-trust-chain</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/25/rundeck-630-conditional-logic-trust-chain/"><![CDATA[<p>Self-hosted Runbook Automation/Rundeck 6.3.0이 2026-10-22에 조기 릴리스되었습니다. 평소 순서(대개 SaaS → self-hosted)와 다르게 self-hosted가 먼저 나온 이유가 명확합니다. 공식 Docker 이미지의 CA trust store가 eMudhra intermediate CA compromise를 그대로 신뢰하고 있었고, 6.3.0에서 “신뢰하지 않도록” 패치된 이미지가 제공됐기 때문입니다. 또한 5.20.0부터 Early Access로 존재하던 Conditional Logic가 6.3.0에서 GA로 전환되면서(그리고 기본 활성화되면서) 운영 자동화의 설계 방식이 바뀔 지점이 생겼습니다.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>오늘은 2026-10-25 KST 기준으로, 내가 Rundeck를 운영 자동화 오케스트레이터로 쓰면서 실제로 겪었던 두 가지 비용을 6.3.0의 변화로 어떻게 줄일지에 집중합니다.</p>

<ul>
  <li>워크플로가 복잡해질수록 <strong>잡 복제(job copy/paste)</strong> 가 설계 부채가 되는 문제</li>
  <li>내가 만든 애플리케이션 코드와 무관하게, <strong>Docker 이미지의 신뢰 체인(trust store)</strong> 이 깨지는 사건이 터졌을 때 “검증 → 재배포”를 파이프라인 표준으로 만들지 못하면 복구가 매번 수작업이 되는 문제</li>
</ul>

<h2 id="630에서-실제로-바뀐-것-ga-기능과-운영-리스크가-같이-들어왔다">6.3.0에서 실제로 바뀐 것: GA 기능과 운영 리스크가 같이 들어왔다</h2>

<p>6.3.0 릴리스 노트의 요약은 단순합니다. Conditional Logic GA, 보안 하드닝, 그리고 eMudhra intermediate CA 이슈로 인한 공식 Docker 이미지 패치가 핵심입니다. 특히 “SaaS는 영향 없음, self-hosted Docker 배포는 6.3.0으로 올리고 재배포하라”는 문장이 이 릴리스의 성격을 규정합니다.<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>여기서 중요한 포인트는 두 가지입니다.</p>

<p>1) Conditional Logic는 기능이 아니라 운영 방식의 변화입니다. “옵션 값/노드 속성/이전 스텝의 출력”을 기반으로 UI/워크플로 엔진이 분기할 수 있으면, 잡을 분기별로 따로 만들 필요가 줄어듭니다. 6.3.0에서는 Runbook Automation 기준으로 기본 활성화가 됐고, 5.20.0~6.2.x에서는 feature flag가 필요했습니다.<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>2) eMudhra 사건은 코드 취약점이 아니라 “컨테이너가 들고 있는 trust store가 현실을 따라가지 못해서 생기는 리스크”를 상기시킵니다. 릴리스 노트/보안 권고 모두 “컨테이너 안에서 apt-get upgrade 하는 건 지속적인 해결책이 아니고, 업데이트된 공식 이미지를 pull해서 재배포하라”고 못 박고 있습니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>게다가 6.3.0에는 작지만 은근히 깨지는 변경도 있습니다. <code class="language-plaintext highlighter-rouge">${job.serverUrl}</code> 컨텍스트 변수가 더 이상 trailing slash(<code class="language-plaintext highlighter-rouge">/</code>)로 끝나지 않습니다. 기존에 <code class="language-plaintext highlighter-rouge">${job.serverUrl}api/...</code> 같은 식으로 붙여 쓰던 스텝은 조용히 깨집니다.<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="잡-복제가-왜-비용이-되는가-실패는-실행-중이-아니라-수정-중에-난다">잡 복제가 왜 비용이 되는가: 실패는 실행 중이 아니라 “수정 중”에 난다</h2>

<p>Rundeck를 오래 쓰면, 조직의 운영 요구사항이 잡의 개수로 직접 반영됩니다.</p>

<ul>
  <li>dev/staging/prod 환경별</li>
  <li>blue/green/canary/rolling 배포 전략별</li>
  <li>노드군(ASG, k8s node pool, 온프레미스 랙)별</li>
  <li>승인(approvals)/점검(precheck)/롤백(rollback) 유무별</li>
</ul>

<p>초기에는 잡을 복제해서 빠르게 쌓는 게 속도가 납니다. 문제는 “다음 수정”부터입니다.</p>

<ul>
  <li>공통 로직이 20개 잡에 흩어지면, 보안 패치나 옵션 추가 같은 변경이 20번 발생합니다.</li>
  <li>어느 잡이 최신인지 애매해져서 운영자가 보수적으로 행동합니다. 결과적으로 배포 리드타임이 늘어납니다.</li>
  <li>잡 스펙이 SCM으로 관리되더라도, 분기별 잡이 많으면 리뷰 비용이 증가합니다.</li>
</ul>

<p>나는 이걸 개발 코드의 중복보다 더 위험하다고 봅니다. 개발 코드는 테스트/빌드 파이프라인이 강제하지만, 운영 잡은 “성공한 이력”이 신뢰를 만들어서 쉽게 방치되기 때문입니다.</p>

<p>Conditional Logic는 여기서 “잡 복제 대신 분기 표현을 워크플로 자체에 넣는” 선택지를 제공합니다. 5.20.0에서 Early Access로 나온 뒤, 6.1.0에서 Node First 전략까지 확장됐고, 6.2.0에서 invertLogic(AND/OR 그룹 토글) 같은 유지보수용 장치가 추가됐으며, 6.3.0에서 GA+기본 활성화로 자리 잡았습니다.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<h2 id="conditional-logic가-줄여주는-중복의-종류-잡-수보다-정책-수를-줄인다">Conditional Logic가 줄여주는 중복의 종류: “잡 수”보다 “정책 수”를 줄인다</h2>

<p>Conditional Logic는 크게 두 축으로 나뉩니다.</p>

<ul>
  <li>Node Conditional Logic: 노드마다 조건을 평가해서 노드 단위로 분기합니다. 예를 들어 <code class="language-plaintext highlighter-rouge">node.tags</code>에 <code class="language-plaintext highlighter-rouge">db</code>가 있으면 DB용 스텝만 수행합니다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
  <li>Workflow Conditional Logic: 실행 전체에서 한 번 조건을 평가해 분기합니다. 예를 들어 <code class="language-plaintext highlighter-rouge">${option.env}</code>가 <code class="language-plaintext highlighter-rouge">prod</code>인 경우에만 Change Ticket 생성/승인을 수행합니다.<sup id="fnref:4:1" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<p>그리고 조건식을 만들 때, 단순히 option만 보는 게 아니라 “이전 스텝 출력(prior step outputs)”을 참조할 수 있게 되는 게 운영적으로 큽니다. 6.3.0에서 Prior Steps 탭과 built-in step output capture가 가능해졌고, 플러그인이 <code class="language-plaintext highlighter-rouge">@PluginOutput</code>을 노출하는 경우 log filter 없이도 조건식에서 참조할 수 있습니다. 커맨드/스크립트라면 log filter의 <code class="language-plaintext highlighter-rouge">${data.&lt;name&gt;}</code> 또는 <code class="language-plaintext highlighter-rouge">${&lt;stepNum&gt;:exec.exitCode*}</code> 같은 형태로 연결할 수 있다고 문서에 명시돼 있습니다.<sup id="fnref:4:2" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<p>이 기능을 “if/else”로만 보면 큰 감흥이 없습니다. 실제로는 정책을 모델링하는 비용을 줄입니다.</p>

<ul>
  <li>배포 전략이 3개면 잡을 3개 만들지 않고, <code class="language-plaintext highlighter-rouge">strategy</code> 옵션 하나로 흡수합니다.</li>
  <li>환경별로 다른 보호 장치(예: prod는 승인 필수, dev는 생략)를 잡 복제가 아니라 분기 정책으로 표현합니다.</li>
  <li>동일한 잡을 호출하는 외부 시스템(ChatOps, PagerDuty, Jira, GitHub Actions 등)이 있어도, 엔드포인트/잡 ID를 늘리지 않고 옵션만 바꿉니다.</li>
</ul>

<p>이건 “잡 수 감소”보다 “잡 API surface 감소”가 핵심입니다.</p>

<h2 id="conditional-logic를-적용하면-안-되는-경우-분기-수가-아니라-책임-경계가-먼저다">Conditional Logic를 적용하면 안 되는 경우: 분기 수가 아니라 책임 경계가 먼저다</h2>

<p>Conditional Logic가 만능은 아닙니다. 문서에도 제한이 분명합니다.</p>

<ul>
  <li>Ruleset 워크플로 전략에서는 지원하지 않습니다. (Sequential/Parallel/Node First에서만 동작)<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></li>
  <li>JSON/YAML 포맷에서는 지원하지만 XML은 지원하지 않습니다. 이미 XML 기반으로 잡을 관리하고 있다면 포맷 전환 비용이 먼저 발생합니다.<sup id="fnref:4:3" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
  <li>Conditional nesting은 무한이 아닙니다. (문서상 1단계 중첩 제한이 명시돼 있습니다.)<sup id="fnref:4:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<p>내 경험 기준으로는 다음 상황에서 Conditional Logic로 억지로 합치면 더 망가집니다.</p>

<ul>
  <li>권한/승인 흐름이 완전히 다른 잡을 하나로 묶는 경우 (prod 승인과 dev 무승인 같은 수준을 넘어, 감사 로그/권한 모델이 갈라질 때)</li>
  <li>실패의 의미가 다른 작업을 한 잡으로 합치는 경우 (예: “프로비저닝”과 “배포”를 하나로 묶고 옵션으로 분기)</li>
  <li>외부 연동(PagerDuty/ServiceNow 등)이 환경마다 다른 테넌트/자격증명을 쓰는데, 키 관리/시크릿 경계가 정리되지 않은 경우</li>
</ul>

<p>Conditional Logic는 “잡 복제 비용”을 줄이지만, “도메인 경계”를 대신 만들어주지 않습니다.</p>

<h2 id="실전-리팩터링-복제된-배포-잡-6개를-1개로-줄이는-구조">실전 리팩터링: 복제된 배포 잡 6개를 1개로 줄이는 구조</h2>

<p>여기서는 Kubernetes에 배포하는 현실적인 패턴을 기준으로 잡을 구성합니다.</p>

<ul>
  <li>입력: <code class="language-plaintext highlighter-rouge">env</code>, <code class="language-plaintext highlighter-rouge">strategy</code>, <code class="language-plaintext highlighter-rouge">service</code>, <code class="language-plaintext highlighter-rouge">image</code>, <code class="language-plaintext highlighter-rouge">chart_version</code>, <code class="language-plaintext highlighter-rouge">dry_run</code></li>
  <li>공통 단계: preflight(클러스터 컨텍스트/권한/네임스페이스 확인), 배포, 검증, (필요 시) 롤백</li>
  <li>분기: env가 prod면 승인/추가 검증 포함, strategy가 canary면 canary 단계가 추가</li>
</ul>

<p>Conditional Logic가 없을 때 흔한 구조는 이렇습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">deploy-service-dev-rolling</code></li>
  <li><code class="language-plaintext highlighter-rouge">deploy-service-staging-rolling</code></li>
  <li><code class="language-plaintext highlighter-rouge">deploy-service-prod-rolling</code></li>
  <li><code class="language-plaintext highlighter-rouge">deploy-service-dev-canary</code></li>
  <li><code class="language-plaintext highlighter-rouge">deploy-service-staging-canary</code></li>
  <li><code class="language-plaintext highlighter-rouge">deploy-service-prod-canary</code></li>
</ul>

<p>옵션이 늘어나면 이 조합은 기하급수로 커집니다. Conditional Logic로 바꾸면 잡은 “1개+정책”으로 재구성됩니다.</p>

<h3 id="잡-정의yaml-conditional-step으로-prod-가드레일과-canary-경로를-넣기">잡 정의(YAML): conditional step으로 prod 가드레일과 canary 경로를 넣기</h3>

<p>아래 예시는 Rundeck job YAML에서 <code class="language-plaintext highlighter-rouge">type: conditional</code>을 사용하는 형태입니다. 문서에 나온 구조 그대로 <code class="language-plaintext highlighter-rouge">conditionGroups</code>(OR), 그룹 내부(AND), <code class="language-plaintext highlighter-rouge">subSteps</code>를 사용합니다.<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">deploy-k8s-service</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">platform/deploy</span>
  <span class="na">description</span><span class="pi">:</span> <span class="s">k8s 배포(rolling/canary), env별 가드레일을 conditional로 분리</span>
  <span class="na">loglevel</span><span class="pi">:</span> <span class="s">INFO</span>
  <span class="na">scheduleEnabled</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">executionEnabled</span><span class="pi">:</span> <span class="no">true</span>

  <span class="na">options</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">env</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">values</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">dev</span>
        <span class="pi">-</span> <span class="s">staging</span>
        <span class="pi">-</span> <span class="s">prod</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">strategy</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">values</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">rolling</span>
        <span class="pi">-</span> <span class="s">canary</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">service</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">image</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">example.registry.local/myapp@sha256:... 또는 tag</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">chart_version</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">dry_run</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">values</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s2">"</span><span class="s">false"</span>
        <span class="pi">-</span> <span class="s2">"</span><span class="s">true"</span>

  <span class="na">sequence</span><span class="pi">:</span>
    <span class="na">strategy</span><span class="pi">:</span> <span class="s">node-first</span>
    <span class="na">keepgoing</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">commands</span><span class="pi">:</span>
      <span class="c1"># 1) 공통: 배포 준비</span>
      <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
          <span class="s">set -euo pipefail</span>
          <span class="s">echo "[preflight] env=${option.env} service=${option.service} strategy=${option.strategy}"</span>
          <span class="s">./scripts/kube-context.sh "${option.env}"</span>
          <span class="s">./scripts/ns-check.sh "${option.env}" "${option.service}"</span>

      <span class="c1"># 2) prod에서만: 실행 전 추가 가드레일</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">conditional</span>
        <span class="na">nodeStep</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">prod-guardrails</span>
        <span class="na">invertLogic</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">conditionGroups</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.env}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">=="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod"</span>
        <span class="na">subSteps</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">set -euo pipefail</span>
              <span class="s">echo "[prod] extra checks..."</span>
              <span class="s">./scripts/require-change-ticket.sh "${option.service}" \</span>
                <span class="s">"${option.image}" "${option.chart_version}"</span>

      <span class="c1"># 3) dry_run이면 여기서 종료</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">conditional</span>
        <span class="na">nodeStep</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">dry-run-stop</span>
        <span class="na">conditionGroups</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.dry_run}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">=="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
        <span class="na">subSteps</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">echo "[dry-run] stop before deploy"</span>

      <span class="c1"># 4) canary 전략</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">conditional</span>
        <span class="na">nodeStep</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">canary-path</span>
        <span class="na">conditionGroups</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.strategy}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">=="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">canary"</span>
        <span class="na">subSteps</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">set -euo pipefail</span>
              <span class="s">./scripts/deploy-canary.sh "${option.env}" "${option.service}" \</span>
                <span class="s">"${option.image}" "${option.chart_version}"</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">set -euo pipefail</span>
              <span class="s">./scripts/verify-canary.sh "${option.env}" "${option.service}"</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">set -euo pipefail</span>
              <span class="s">./scripts/promote-canary.sh "${option.env}" "${option.service}"</span>

      <span class="c1"># 5) rolling 전략</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">conditional</span>
        <span class="na">nodeStep</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">rolling-path</span>
        <span class="na">conditionGroups</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.strategy}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">=="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">rolling"</span>
        <span class="na">subSteps</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">set -euo pipefail</span>
              <span class="s">./scripts/deploy-rolling.sh "${option.env}" "${option.service}" \</span>
                <span class="s">"${option.image}" "${option.chart_version}"</span>

      <span class="c1"># 6) 공통: post-verify</span>
      <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
          <span class="s">set -euo pipefail</span>
          <span class="s">./scripts/verify-service.sh "${option.env}" "${option.service}"</span>
</code></pre></div></div>

<p>이 YAML이 현실적인 이유는 두 가지입니다.</p>

<ul>
  <li>Node First 전략을 기준으로 잡으면, 한 노드에서 워크플로 전체를 끝내고 다음 노드로 넘어가므로 배포/검증이 섞여서 레이스가 나는 걸 피할 수 있습니다. Conditional Logic는 6.1.0에서 Node First에서도 동작하도록 확장됐습니다.<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></li>
  <li>prod 가드레일을 “잡 분기”가 아니라 “워크플로 정책”으로 넣으면, 외부 호출자(예: ChatOps)가 prod 잡을 따로 찾지 않아도 됩니다. 운영 정책이 UI 상에서도 한 잡 안에 보이기 때문에 리뷰/감사 관점에서도 낫습니다.</li>
</ul>

<h3 id="실행-가능한-스크립트예시-canaryrolling을-실제-kubectlhelm로-돌리기">실행 가능한 스크립트(예시): canary/rolling을 실제 kubectl/helm로 돌리기</h3>

<p>아래는 로컬에서도 동작하는 수준으로 쪼갠 스크립트 예시입니다. (환경별 kubeconfig/context를 잡는 방식은 조직마다 달라서, 여기서는 context 이름만 매핑한다고 가정합니다.)</p>

<p><code class="language-plaintext highlighter-rouge">scripts/kube-context.sh</code></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">ENV</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
<span class="k">case</span> <span class="s2">"</span><span class="nv">$ENV</span><span class="s2">"</span> <span class="k">in
  </span>dev<span class="p">)</span>     <span class="nv">CTX</span><span class="o">=</span><span class="s2">"k8s-dev"</span> <span class="p">;;</span>
  staging<span class="p">)</span> <span class="nv">CTX</span><span class="o">=</span><span class="s2">"k8s-staging"</span> <span class="p">;;</span>
  prod<span class="p">)</span>    <span class="nv">CTX</span><span class="o">=</span><span class="s2">"k8s-prod"</span> <span class="p">;;</span>
  <span class="k">*</span><span class="p">)</span> <span class="nb">echo</span> <span class="s2">"unknown env=</span><span class="nv">$ENV</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nb">exit </span>2 <span class="p">;;</span>
 <span class="k">esac</span>

<span class="nb">echo</span> <span class="s2">"[kube] use-context </span><span class="nv">$CTX</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2
kubectl config use-context <span class="s2">"</span><span class="nv">$CTX</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">scripts/deploy-rolling.sh</code></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">ENV</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
<span class="nv">SVC</span><span class="o">=</span><span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span>
<span class="nv">IMAGE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$3</span><span class="s2">"</span>
<span class="nv">CHART_VERSION</span><span class="o">=</span><span class="s2">"</span><span class="nv">$4</span><span class="s2">"</span>

<span class="nv">NAMESPACE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SVC</span><span class="s2">"</span>
<span class="nv">RELEASE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SVC</span><span class="s2">"</span>

helm upgrade <span class="nt">--install</span> <span class="s2">"</span><span class="nv">$RELEASE</span><span class="s2">"</span> <span class="s2">"./charts/</span><span class="nv">$SVC</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--namespace</span> <span class="s2">"</span><span class="nv">$NAMESPACE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--version</span> <span class="s2">"</span><span class="nv">$CHART_VERSION</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--set</span> image.ref<span class="o">=</span><span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--atomic</span> <span class="nt">--timeout</span> 10m

kubectl <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$NAMESPACE</span><span class="s2">"</span> rollout status deploy/<span class="s2">"</span><span class="nv">$SVC</span><span class="s2">"</span> <span class="nt">--timeout</span><span class="o">=</span>10m
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">scripts/verify-service.sh</code></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">ENV</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
<span class="nv">SVC</span><span class="o">=</span><span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span>
<span class="nv">NAMESPACE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SVC</span><span class="s2">"</span>

<span class="c"># 예시: readiness + 간단한 HTTP health check</span>
kubectl <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$NAMESPACE</span><span class="s2">"</span> get pod <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SVC</span><span class="s2">"</span> <span class="nt">-o</span> wide

<span class="c"># 포트포워딩은 Rundeck 실행 노드/네트워크에 따라 다르니,</span>
<span class="c"># 여기서는 Ingress/Service endpoint를 curl로 확인한다고 가정</span>
<span class="nv">URL</span><span class="o">=</span><span class="s2">"https://</span><span class="k">${</span><span class="nv">SVC</span><span class="k">}</span><span class="s2">.</span><span class="k">${</span><span class="nv">ENV</span><span class="k">}</span><span class="s2">.example.internal/health"</span>

<span class="nb">echo</span> <span class="s2">"[verify] curl </span><span class="nv">$URL</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2
curl <span class="nt">-fsS</span> <span class="s2">"</span><span class="nv">$URL</span><span class="s2">"</span> | <span class="nb">head</span> <span class="nt">-c</span> 200
<span class="nb">echo</span>
</code></pre></div></div>

<p>이런 스크립트 분해가 Conditional Logic와 잘 맞는 이유는, 분기 자체는 Rundeck에서 표현하고, 실행 단위는 script로 관리하면 스텝 재사용이 쉬워지기 때문입니다. 스크립트는 CI에서 lint/test도 가능해집니다.</p>

<h2 id="630-마이그레이션에서-놓치기-쉬운-함정-jobserverurl-trailing-slash-제거">6.3.0 마이그레이션에서 놓치기 쉬운 함정: job.serverUrl trailing slash 제거</h2>

<p>6.3.0에서 <code class="language-plaintext highlighter-rouge">${job.serverUrl}</code>이 더 이상 <code class="language-plaintext highlighter-rouge">/</code>로 끝나지 않습니다. 릴리스 노트의 설명은 “절대 경로를 안전하게 붙일 수 있게 하기 위해서”입니다. 예시도 <code class="language-plaintext highlighter-rouge">${job.serverUrl}/api/50/projects</code>처럼 “슬래시를 명시적으로 넣어라”는 방향입니다.<sup id="fnref:1:4" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>깨지는 패턴은 명확합니다.</p>

<ul>
  <li>이전: <code class="language-plaintext highlighter-rouge">${job.serverUrl}api/50/...</code> (serverUrl이 <code class="language-plaintext highlighter-rouge">/</code>로 끝났다는 전제)</li>
  <li>이후: <code class="language-plaintext highlighter-rouge">${job.serverUrl}api/50/...</code> → <code class="language-plaintext highlighter-rouge">https://rundeck.example.comapi/50/...</code> 같은 형태가 되어 실패</li>
</ul>

<p>운영에서 더 곤란한 건, 이게 “컴파일 에러”가 아니라 “런타임에서 외부 호출 실패”로 터진다는 점입니다. 특히 webhook callback URL을 job.serverUrl 기반으로 만들거나, Rundeck API를 자기 자신에게 호출하는 형태(자기 참조)를 썼던 잡은 한 번에 잡아야 합니다.</p>

<p>내가 적용한 표준은 단순했습니다.</p>

<p>1) 잡 정의를 SCM으로 관리한다면, 레포에서 <code class="language-plaintext highlighter-rouge">${job.serverUrl}api</code> 패턴을 grep해서 전부 교체합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-R</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{job.serverUrl}api"</span> <span class="nt">-n</span> ./rundeck-jobs <span class="o">||</span> <span class="nb">true</span>
</code></pre></div></div>

<p>2) 교체 규칙은 하나로 고정합니다.</p>

<ul>
  <li>무조건 <code class="language-plaintext highlighter-rouge">${job.serverUrl}/api/...</code>로 작성</li>
  <li>URL join을 스크립트에서 할 때는 base URL 끝의 <code class="language-plaintext highlighter-rouge">/</code>를 제거하는 normalize 함수를 둠</li>
</ul>

<p>3) 잡 실행 노드가 reverse proxy 뒤에 있을 때(특히 TLS termination) URL과 cookie 보안 속성 관련 이슈가 터질 수 있는데, 6.3.0에는 <code class="language-plaintext highlighter-rouge">rundeck-cli</code> 세션 로그인 관련 리그레션 수정도 포함되어 있었습니다. 이 범주는 “serverUrl 관련 변경은 생각보다 파급이 넓다”는 신호로 봤습니다.<sup id="fnref:1:5" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="emudhra-intermediate-ca-compromise-신뢰-체인이-깨지면-업데이트가-아니라-재배포가-된다">eMudhra intermediate CA compromise: 신뢰 체인이 깨지면 ‘업데이트’가 아니라 ‘재배포’가 된다</h2>

<p>사건 자체를 요약하면 이렇습니다.</p>

<ul>
  <li>2026-10-20에 eMudhra가 운영하는 intermediate CA 8개의 private key가 compromise됐고, “공개된 인증서에서 키를 복구할 수 있다”고 공개되었습니다. Rundeck 문서도 “키는 public certificate에서 복구 가능”하다고 명시합니다.<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>이 intermediate들은 Mozilla/Microsoft/Apple/Chrome 루트 프로그램에서 신뢰되는 루트들(emSign ECC Root CA - G3 등)로 체인이 이어질 수 있고, on-path 공격자가 TLS 서버를 가장할 수 있습니다.<sup id="fnref:2:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>브라우저는 revocation/CT/OneCRL 같은 메커니즘으로 상대적으로 방어가 되지만, 서버/컨테이너의 non-browser TLS 클라이언트(Java/Go/Python/Node/curl 등)는 trust store 업데이트가 늦으면 그대로 노출됩니다. 이 방향성은 Rundeck 권고와 Mozilla의 공지에서도 일치합니다.<sup id="fnref:2:3" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<p>여기서 Rundeck 관점의 핵심은 “Rundeck 코드가 취약한 게 아니어도, Rundeck가 올라가는 컨테이너 이미지의 trust store가 사고를 포함할 수 있다”는 점입니다. Rundeck 보안 권고는 영향을 받는 범위를 아주 구체적으로 구분합니다.</p>

<ul>
  <li>영향 있음: 공식 Rundeck/Runbook Automation Docker 이미지(6.3.0 이전)<sup id="fnref:2:4" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>영향 없음: Runbook Automation SaaS<sup id="fnref:2:5" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
  <li>케이스 바이 케이스: RPM/DEB/WAR 같이 호스트 설치(호스트 OS/JRE trust store 관리 상태에 달림)<sup id="fnref:2:6" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<p>그리고 remediation은 “업그레이드 후 재배포”입니다. 특히 컨테이너 안에서 <code class="language-plaintext highlighter-rouge">apt-get upgrade</code>로 해결하려는 습관을 잘라냅니다. 재생성되면 사라지는 변경이라서 운영 표준으로 삼으면 안 됩니다.<sup id="fnref:2:7" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<h3 id="검증-커맨드가-curl-한-줄로-제공될-때의-의미">검증 커맨드가 ‘curl 한 줄’로 제공될 때의 의미</h3>

<p>Rundeck 권고는 검증 방법도 제공합니다. 컨테이너/호스트에서 아래 URL을 curl 했을 때 certificate error가 나야 “더 이상 취약 intermediate를 신뢰하지 않는다”는 뜻입니다. 성공 응답이 오면 아직 신뢰하고 있으니 조치가 필요합니다.<sup id="fnref:2:8" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>다만 같은 문서가 즉시 경고합니다. 이 체크는 curl이 OS trust store를 읽기 때문에 “Java trust store는 별개”라는 점입니다. Rundeck가 Java 위에서 동작한다는 사실을 생각하면, 컨테이너 이미지에서는 둘 다 패치돼야 하고, 호스트 설치에서는 OS/JRE 업데이트 루트가 분리될 수 있습니다.<sup id="fnref:2:9" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>이 메시지를 운영 규칙으로 번역하면 다음이 됩니다.</p>

<ul>
  <li>배포 파이프라인에서 “curl 검증”을 추가하되, 그것을 최종 안전 판정으로 삼지 않는다.</li>
  <li>Java/JRE trust store 업데이트 경로를 별도로 문서화한다.</li>
  <li>컨테이너 기반 배포에서는 “패치=재빌드/재배포”를 원칙으로 한다.</li>
</ul>

<h2 id="배포-파이프라인에서-신뢰-체인-사고를-표준-처리로-만드는-방법">배포 파이프라인에서 ‘신뢰 체인 사고’를 표준 처리로 만드는 방법</h2>

<p>신뢰 체인 사고는 보통 이렇게 터집니다.</p>

<ul>
  <li>어느 날 갑자기 외부 API 호출이 TLS handshake 실패로 망가지거나</li>
  <li>반대로, “원래 실패해야 하는 연결이 성공”하면서 위험한 상태가 생깁니다</li>
</ul>

<p>Rundeck의 eMudhra 권고는 후자에 가깝습니다. “취약 intermediate를 신뢰하면 curl이 성공한다”가 위험 신호입니다.<sup id="fnref:2:10" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>나는 이런 사고를 CI/CD 파이프라인에서 처리할 때, 기술보다 ‘표준 단위’를 먼저 고정했습니다.</p>

<ul>
  <li>표준 입력: (image ref, expected policy)</li>
  <li>표준 출력: (pass/fail, 재배포 티켓/PR 링크)</li>
  <li>표준 실행 위치: (CI runner, 혹은 전용 검증 노드)</li>
</ul>

<p>Rundeck를 쓰는 조직에서는 이걸 Rundeck 잡으로 만들 수도 있고, CI에서만 돌릴 수도 있습니다. 핵심은 “사고 대응을 사람이 기억하는 체크리스트로 두지 말고, 실행 가능한 잡/스텝으로 박아두는 것”입니다. 이 방향은 과거에 CISA KEV 기반 패치 우선순위를 자동화했던 글에서 말한 것과 동일한 결론으로 이어집니다. <a href="https://daewooki.github.io/posts/kev-patch-priority-engine/">CISA KEV로 패치 우선순위 엔진 만들기</a></p>

<h3 id="1-rundeck-자체-이미지-태그가-아니라-digest로-고정하고-pullredeploy를-강제">(1) Rundeck 자체 이미지: 태그가 아니라 digest로 고정하고, pull→redeploy를 강제</h3>

<p>먼저 “공식 이미지가 어떤 태그로 배포되는지”가 기준점입니다. Docker Hub 기준 rundeck/rundeck에는 <code class="language-plaintext highlighter-rouge">6.3.0</code>과 빌드 태그 <code class="language-plaintext highlighter-rouge">6.3.0-20261022</code>가 같이 올라와 있고(같은 digest), 이것이 6.3.0 GA 이미지의 식별자입니다.<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup></p>

<p>운영 표준은 다음처럼 잡았습니다.</p>

<ul>
  <li>k8s/compose/nomad 어디든, 태그만 쓰지 말고 digest를 고정한다.</li>
  <li>업스트림 사고가 났을 때는 digest가 바뀌는 순간을 “변경 이벤트”로 감지한다.</li>
</ul>

<p>digest를 얻는 건 로컬에서도 됩니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">IMAGE</span><span class="o">=</span><span class="s2">"rundeck/rundeck:6.3.0-20261022"</span>

docker pull <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span>

docker inspect <span class="nt">--format</span> <span class="s1">'{{index .RepoDigests 0}}'</span> <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span>
<span class="c"># 예상 출력 형태:</span>
<span class="c"># rundeck/rundeck@sha256:aaaaaaaa....</span>
</code></pre></div></div>

<p>이걸 Helm values나 compose, 혹은 내부 Golden Image Dockerfile에 박습니다.</p>

<div class="language-Dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># digest는 위 커맨드로 구한 값을 사용</span>
<span class="k">FROM</span><span class="s"> rundeck/rundeck@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>

<span class="c"># (조직 표준) 플러그인/CA 번들/설정 등을 여기에 고정</span>
</code></pre></div></div>

<p>이 단계가 중요한 이유는, “태그는 같지만 이미지가 교체되는” 상황이 실제로 있고, 반대로 “태그가 올라가기 전에 digest만 바뀌는” 내부 레지스트리 미러링 구조도 존재하기 때문입니다.</p>

<h3 id="2-ca-trust-store-검증-컨테이너-내부에서-curl-체크를-파이프라인-게이트로-둔다">(2) CA trust store 검증: 컨테이너 내부에서 curl 체크를 파이프라인 게이트로 둔다</h3>

<p>Rundeck 권고의 curl 검증은 그대로 자동화하기 좋습니다. 단, 성공/실패의 의미를 반대로 해석해야 합니다.</p>

<ul>
  <li>certificate error(실패) = 통과(더 이상 신뢰하지 않음)</li>
  <li>200 OK(성공) = 실패(아직 신뢰함)</li>
</ul>

<p>아래는 컨테이너 이미지에 대해 검사하는 실행 가능한 스크립트입니다.</p>

<p><code class="language-plaintext highlighter-rouge">scripts/check-emudhra-trust.sh</code></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">IMAGE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>

docker run <span class="nt">--rm</span> <span class="nt">--entrypoint</span> bash <span class="s2">"</span><span class="nv">$IMAGE</span><span class="s2">"</span> <span class="nt">-lc</span> <span class="s1">'
  set -euo pipefail
  URL="https://dv-g3b-emudhra.we-have-a-problem.com/"
  echo "[check] curl $URL" &gt;&amp;2

  if curl -fsS "$URL" &gt;/dev/null 2&gt;&amp;1; then
    echo "FAIL: this image still trusts compromised intermediates" &gt;&amp;2
    exit 10
  else
    echo "PASS: curl failed as expected (distrust)" &gt;&amp;2
  fi
'</span>
</code></pre></div></div>

<p>실행 예:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod</span> +x scripts/check-emudhra-trust.sh

scripts/check-emudhra-trust.sh rundeck/rundeck:6.3.0-20261022
<span class="c"># 예상 출력(개념):</span>
<span class="c"># PASS: curl failed as expected (distrust)</span>
</code></pre></div></div>

<p>이 체크가 공식 문서의 “verification” 섹션과 동일하다는 점이 중요합니다. 사람마다 다른 사이트/방법으로 검증하면, 사고 때마다 결론이 갈리기 쉽습니다.<sup id="fnref:2:11" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<h3 id="3-재배포-표준-컨테이너-안에서-패치를-금지하고-redeploy만-허용">(3) 재배포 표준: “컨테이너 안에서 패치”를 금지하고, redeploy만 허용</h3>

<p>Rundeck 권고가 명시적으로 말하는 바는 이겁니다. 실행 중인 컨테이너에서 <code class="language-plaintext highlighter-rouge">apt-get upgrade</code>로 수정해도 컨테이너를 재생성하면 사라지니 지속적인 해결이 아니라는 것.<sup id="fnref:2:12" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>이걸 조직 표준으로 옮기면 이렇게 됩니다.</p>

<ul>
  <li>(금지) 컨테이너 내부에서 운영자가 손으로 패치</li>
  <li>(허용) 이미지 교체 → 재배포 → 검증 잡 실행 → 변경 이력 남김</li>
</ul>

<p>이 표준은 Rundeck만의 얘기가 아닙니다. base image의 CA bundle/JRE trust store가 사고를 포함하는 순간, 재빌드/재배포가 유일하게 반복 가능한 복구 시나리오가 됩니다.</p>

<p>나는 이 규칙을 “운영 문서”가 아니라 “파이프라인 정책”으로 구현하는 편을 선호합니다. 개발자 머신/CI에 툴 설치를 표준화했던 방식과 동일한 결론입니다. <a href="https://daewooki.github.io/posts/standardize-aws-toolchain-installs/">AWS Tools Installer V2로 개발자 머신과 CI의 AWS 툴 설치를 표준화하기</a></p>

<h2 id="conditional-logic로-신뢰-체인-사고-대응까지-흡수하기-잡-복제를-줄이면서-표준-대응을-붙인다">Conditional Logic로 ‘신뢰 체인 사고 대응’까지 흡수하기: 잡 복제를 줄이면서 표준 대응을 붙인다</h2>

<p>여기서 흥미로운 결합이 가능합니다.</p>

<ul>
  <li>기존에는 “배포 잡”과 “보안 점검 잡”이 분리되며, 환경별로 또 복제됩니다.</li>
  <li>Conditional Logic가 있으면 “배포 잡 내부”에 신뢰 체인 점검을 옵션 기반으로 붙일 수 있습니다.</li>
</ul>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">security_precheck=true/false</code> 옵션을 두고, prod에서는 강제(true)로 고정하고 dev에서는 선택으로 둡니다. 핵심은 “보안 대응 스텝을 별도 잡으로 만들지 않고도 정책을 집어넣을 수 있다”는 점입니다.</p>

<p>Conditional Logic 문서가 말하는 대표 사례(환경/region 조합을 분기 조합으로 만들지 말고 invertLogic으로 관리하라)도 같은 맥락입니다. 조합 수를 늘리지 말고 정책 축을 유지하라는 메시지입니다.<sup id="fnref:4:5" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<h3 id="배포-잡에-신뢰-체인-프리플라이트를-붙이는-예시">배포 잡에 신뢰 체인 프리플라이트를 붙이는 예시</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">options</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">security_precheck</span>
      <span class="na">required</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">values</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">true"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">false"</span><span class="pi">]</span>

  <span class="na">sequence</span><span class="pi">:</span>
    <span class="na">commands</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">conditional</span>
        <span class="na">nodeStep</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">force-precheck-on-prod</span>
        <span class="na">conditionGroups</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.env}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">=="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod"</span>
        <span class="na">subSteps</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s"># prod는 옵션을 무시하고 강제 수행</span>
              <span class="s">./scripts/check-trust-chain.sh</span>

      <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">conditional</span>
        <span class="na">nodeStep</span><span class="pi">:</span> <span class="no">false</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">optional-precheck</span>
        <span class="na">conditionGroups</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.env}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">!="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod"</span>
            <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${option.security_precheck}"</span>
              <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">=="</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
        <span class="na">subSteps</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">exec</span><span class="pi">:</span> <span class="pi">|-</span>
              <span class="s">./scripts/check-trust-chain.sh</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">check-trust-chain.sh</code>는 “내가 중요하다고 보는 이미지 목록”에 대해 앞서 만든 curl 검증 같은 것을 일괄 수행하게 만들면 됩니다.</p>

<p>이 구조의 이점은 세 가지입니다.</p>

<ul>
  <li>사고가 터졌을 때 배포를 중지시키는 게 아니라, 배포 경로 안에서 프리플라이트를 실패시켜 멈춥니다.</li>
  <li>운영자가 보안 공지를 읽고 기억해서 실행하는 게 아니라, 잡 실행 자체가 정책을 수행합니다.</li>
  <li>잡을 환경별로 복제하지 않아도 “prod는 강제, dev는 선택” 같은 정책 차이를 표현할 수 있습니다.</li>
</ul>

<h2 id="conditional-logic-도입-판단-기준-잡을-합칠지-말지의-기준을-변수가-아니라-변경률로-잡는다">Conditional Logic 도입 판단 기준: 잡을 합칠지 말지의 기준을 ‘변수’가 아니라 ‘변경률’로 잡는다</h2>

<p>Conditional Logic를 도입할지 고민할 때, 나는 다음 질문으로 결정합니다.</p>

<p>1) 이 분기는 앞으로 늘어날 가능성이 큰가?</p>

<ul>
  <li>늘어날 가능성이 크면 Conditional Logic로 흡수하는 편이 좋습니다.</li>
  <li>늘어날 가능성이 낮으면 잡을 분리해서 경계를 명확히 하는 편이 낫습니다.</li>
</ul>

<p>2) 분기 축이 정책인가, 구현인가?</p>

<ul>
  <li>정책(env별 승인/보호 장치, 사고 대응 프리플라이트)은 한 잡 안에서 보이는 편이 운영에 유리합니다.</li>
  <li>구현(전혀 다른 배포 방식, 전혀 다른 대상 시스템)은 잡 분리가 낫습니다.</li>
</ul>

<p>3) 외부 호출자(챗봇/CI/PagerDuty)가 잡 ID를 기준으로 연동하는가?</p>

<ul>
  <li>잡 ID가 많아질수록 외부 시스템의 라우팅 규칙이 복잡해지고, 이것이 또 다른 운영 부채가 됩니다.</li>
</ul>

<p>6.3.0에서 Conditional Logic가 GA로 올라오면서(그리고 기본 활성화되면서) “이 기능을 써도 되나?”가 아니라 “이 기능을 쓰지 않으면 잡 복제가 계속 비용이 되나?”로 질문이 바뀌었다고 느꼈습니다.<sup id="fnref:1:6" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="결론-630은-기능-업데이트가-아니라-운영-모델을-교정하는-릴리스다">결론: 6.3.0은 기능 업데이트가 아니라 운영 모델을 교정하는 릴리스다</h2>

<p>이번 6.3.0의 결을 한 문장으로 정리하면 이렇습니다.</p>

<ul>
  <li>Conditional Logic GA는 잡 복제를 줄여서 자동화의 변경 비용을 낮추는 방향이고,<sup id="fnref:1:7" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
  <li>eMudhra intermediate CA 대응은 “컨테이너 기반 운영에서 신뢰 체인 사고는 redeploy로 수렴한다”는 현실을 강제합니다.<sup id="fnref:2:13" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<p>여기에 <code class="language-plaintext highlighter-rouge">${job.serverUrl}</code> trailing slash 제거 같은 작은 변경은 “운영 잡이 결국 소프트웨어”라는 사실을 다시 보여줍니다. 자동화가 커질수록, 잡은 더 이상 클릭으로 만드는 운영 문서가 아니라, 리팩터링/검증/배포가 필요한 코드에 가까워집니다.<sup id="fnref:1:8" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>내 결론은 단순합니다. Conditional Logic로 잡 복제를 줄인 만큼, 배포 파이프라인에는 trust chain 검증과 redeploy 표준을 같이 넣어야 운영의 총비용이 내려갑니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://docs.rundeck.com/docs/history/6_x/version-6.3.0.html">Runbook Automation/Rundeck 6.3.0 Release Notes</a></li>
  <li><a href="https://docs.rundeck.com/docs/history/updates/">Recent Updates: Self-Hosted 6.3.0 released ahead of SaaS</a></li>
  <li><a href="https://docs.rundeck.com/docs/history/cves/2026-10-emudhra-ca.html">Security Advisory: eMudhra Intermediate CA Compromise</a></li>
  <li><a href="https://docs.rundeck.com/docs/manual/jobs/conditional-logic.html">Conditional Logic Steps 문서</a></li>
  <li><a href="https://docs.rundeck.com/docs/learning/howto/using-conditional-logic.html">Using Conditional Logic in a Job 가이드</a></li>
  <li><a href="https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html">Configuration File Reference: rundeck.feature.earlyAccessJobConditional.enabled</a></li>
  <li><a href="https://hub.docker.com/r/rundeck/rundeck/tags">Rundeck Docker Hub tags (6.3.0 / 6.3.0-20261022)</a></li>
  <li><a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2083449">Mozilla Bug 2083449: eMudhra intermediate private key compromise</a></li>
  <li><a href="https://blog.mozilla.org/security/2026/10/20/protecting-firefox-users-following-the-compromise-of-emudhra-intermediate-certificates/">Mozilla Security Blog: Protecting Firefox users following the compromise</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/docs/history/6_x/version-6.3.0.html">https://docs.rundeck.com/docs/history/6_x/version-6.3.0.html</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:1:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:1:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:1:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:1:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:1:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/docs/history/cves/2026-10-emudhra-ca.html">https://docs.rundeck.com/docs/history/cves/2026-10-emudhra-ca.html</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:2:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:2:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:2:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:2:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:2:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:2:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:2:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a> <a href="#fnref:2:9" class="reversefootnote" role="doc-backlink">&#8617;<sup>10</sup></a> <a href="#fnref:2:10" class="reversefootnote" role="doc-backlink">&#8617;<sup>11</sup></a> <a href="#fnref:2:11" class="reversefootnote" role="doc-backlink">&#8617;<sup>12</sup></a> <a href="#fnref:2:12" class="reversefootnote" role="doc-backlink">&#8617;<sup>13</sup></a> <a href="#fnref:2:13" class="reversefootnote" role="doc-backlink">&#8617;<sup>14</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/6.1.0/history/5_x/version-5.20.0.html">https://docs.rundeck.com/6.1.0/history/5_x/version-5.20.0.html</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/docs/manual/jobs/conditional-logic.html">https://docs.rundeck.com/docs/manual/jobs/conditional-logic.html</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:4:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:4:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:4:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:4:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:4:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/docs/learning/howto/using-conditional-logic.html">https://docs.rundeck.com/docs/learning/howto/using-conditional-logic.html</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/docs/manual/document-format-reference/job-yaml-v12.html">https://docs.rundeck.com/docs/manual/document-format-reference/job-yaml-v12.html</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://docs.rundeck.com/6.1.0/history/6_x/version-6.1.0.html">https://docs.rundeck.com/6.1.0/history/6_x/version-6.1.0.html</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://hub.docker.com/r/rundeck/rundeck/tags">https://hub.docker.com/r/rundeck/rundeck/tags</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="DevOps" /><category term="Rundeck" /><category term="rundeck" /><category term="runbook-automation" /><category term="conditional-logic" /><category term="docker" /><category term="tls" /><category term="supply-chain-security" /><summary type="html"><![CDATA[Conditional Logic GA로 잡 복제를 줄이고, eMudhra intermediate CA 사고 같은 신뢰 체인 이슈를 배포 파이프라인에서 검증·재배포로 표준화합니다.]]></summary></entry><entry><title type="html">Arista VeloCloud Orchestrator SQLi와 오케스트레이터 방어 설계</title><link href="https://joyuno.github.io/blog/2026/10/25/velocloud-orchestrator-sqli-defense-model/" rel="alternate" type="text/html" title="Arista VeloCloud Orchestrator SQLi와 오케스트레이터 방어 설계" /><published>2026-10-25T10:06:40+09:00</published><updated>2026-10-25T10:06:40+09:00</updated><id>https://joyuno.github.io/blog/2026/10/25/velocloud-orchestrator-sqli-defense-model</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/25/velocloud-orchestrator-sqli-defense-model/"><![CDATA[<h2 id="사건-advisory-0199가-말한-것그리고-말하지-않은-것">사건: Advisory 0199가 말한 것(그리고 말하지 않은 것)</h2>

<p>Arista는 2026년 10월 22일에 Security Advisory 0199를 공개하면서 <strong>CVE-2026-114625</strong>를 다뤘습니다. 요약하면 VeloCloud Orchestrator(VCO) On-Prem에서 SQL injection(CWE-89)로 인해, 원격 공격자가 “privileged internal functionality”에 접근하고 VCO host에 영향을 줄 수 있다는 내용입니다. CVSS v3.1 9.9로 매우 높게 잡혔고, Arista가 “<strong>known to be actively exploited</strong>”라고 명시했습니다.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이 공지에서 특히 중요한 문장은 2개입니다.</p>

<p>1) “어떤 인증 사용자든(operator가 아니라 테넌트 사용자 포함) operator privilege로 상승할 수 있다”</p>

<ul>
  <li>Arista는 “any authenticated user can gain operator privileges and masquerade as a local ‘superuser operator’ user”라고 씁니다. 즉, 취약점의 본질이 단순 데이터 탈취가 아니라 권한 경계가 무너지는 형태입니다.<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>2) “공격 성공 조건이 생각보다 낮다”</p>

<ul>
  <li>“성공 조건은 VCO web interface에 대한 네트워크 접근 + 유효한 인증 세션”이고, 최소 역할이 <strong>Enterprise Read Only</strong>(내장된 테넌트 역할 중 최저)라고 박아 둡니다. 그리고 “VCO is exposed by default”, “There is no configuration that can prevent the exposure”까지 같이 나옵니다. 여기서 운영자가 얻어야 하는 결론은 단순합니다. 설정으로 피해갈 수 있는 종류가 아니고, 노출면이 넓은 배치가 많을 수 있다는 경고입니다.<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>패치 고정 버전도 명확합니다.</p>

<ul>
  <li>5.2.x: 5.2.3.18 미만 영향</li>
  <li>6.1.x: 6.1.3.10 미만 영향</li>
  <li>6.4.x: 6.4.2.12 미만 영향<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>“내 버전이 목록에 없으면 취약하지 않다”는 문장도 있습니다. 다만 Arista는 “End of support software versions have not been assessed”라고 덧붙입니다. 즉, EoS train을 쓰고 있으면 ‘취약/비취약’ 판정을 공지에서 못 받는 상태가 될 수 있습니다.<sup id="fnref:1:4" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>캐나다 Cyber Centre는 2026년 10월 23일자 경보(AV26-1080 Update 1)에서 영향을 받는 버전 범위와 “exploited in the wild”를 재확인했고, 같은 날 CISA가 KEV Database에 추가했다고도 적었습니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<p>(부연) 내 환경에서는 cisa.gov의 KEV 상세 페이지가 403으로 열리지 않았습니다. 그래서 CISA 원문을 직접 인용하진 않고, 캐나다 Cyber Centre가 명시한 “추가되었다”는 사실까지만 확정으로 취급하는 편이 안전합니다.<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<hr />

<h2 id="sd-wan에서-오케스트레이터-계층이-뚫리면-피해가-커지는-이유">SD-WAN에서 ‘오케스트레이터 계층’이 뚫리면 피해가 커지는 이유</h2>

<p>SD-WAN은 흔히 Edge 장비(브랜치 박스) 쪽이 전면에 보이지만, 실제로는 Orchestrator가 관리 plane의 단일 진입점(single pane of glass)이 되는 배치가 많습니다. Arista 문서도 “Orchestrator powers the management plane in the VeloCloud SD-WAN solution”이라고 표현합니다.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>Orchestrator Portal은 네트워크/디바이스 설정을 관리하고, 현재/과거 상태를 조회할 수 있으며, API 클라이언트는 JSON-RPC 또는 REST-like 인터페이스로 같은 기능을 호출할 수 있다고 설명합니다. 특히 “문서에 나온 메서드는 어느 인터페이스로도 호출 가능”이라고 돼 있어, web UI와 자동화 API가 사실상 같은 기능 경계에 있다는 뜻입니다.<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>이 구조에서 Orchestrator 계층은 대략 이런 것들을 한 지점에 모읍니다.</p>

<ul>
  <li>구성 변경 권한(세그먼트, 라우팅/정책, 보안 정책 등)</li>
  <li>토폴로지/상태 데이터(링크 품질, 터널 상태, 이벤트)</li>
  <li>운영자 행위 로그(누가 어떤 설정을 바꿨는지)</li>
  <li>여러 테넌트/파트너 모델(서비스 프로바이더 운영 모델)</li>
</ul>

<p>취약점이 Edge 장비의 data plane에서 터진 게 아니라, Orchestrator의 web/API 계층에서 터졌다는 점이 무서운 이유는, “한 번 뚫리면 운영자가 하는 일을 그대로 공격자가 하게 된다”는 형태로 이어지기 때문입니다.</p>

<hr />

<h2 id="피해-모델-read-only--operator-권한-상승--내부-기능-접근--host-impact">피해 모델: Read Only → operator 권한 상승 → 내부 기능 접근 → host impact</h2>

<p>Advisory 0199는 “SQL injection”이라는 취약점 분류(CWE-89)를 말하지만, 실제 운영 관점에서 더 중요한 것은 권한 경계가 무너진다는 결과입니다. Arista 표현을 그대로 가져오면, 공격자는 operator privilege를 얻고 로컬 “superuser operator”로 가장할 수 있습니다.<sup id="fnref:1:5" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>여기서 피해 모델을 단계로 쪼개면, 방어 우선순위가 더 명확해집니다.</p>

<h3 id="1-초기-발화점-인증이-필요한-취약점의-착시">1) 초기 발화점: “인증이 필요한 취약점”의 착시</h3>

<p>이 취약점은 pre-auth RCE처럼 보이진 않습니다. Arista도 “유효한 authenticated session이 필요”하다고 씁니다.<sup id="fnref:1:6" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>그런데 SD-WAN Orchestrator의 인증은 현실에서 다음 이유로 깨지기 쉽습니다.</p>

<ul>
  <li>Read Only 계정은 만드는 심리적 저항이 낮아, 계정 수가 늘어나기 쉽습니다.</li>
  <li>자동화/운영 연동(API client) 때문에 토큰/크레덴셜이 여러 시스템에 퍼집니다.</li>
  <li>테넌트/파트너 모델에서는 외부 조직 인력에게도 포털 접근이 열릴 수 있습니다.</li>
</ul>

<p>즉, “인증이 필요하니 덜 급하다”는 판단은 관리 plane에서 특히 위험합니다. 인증은 경계가 아니라 공격 표면의 한 부분이고, 낮은 권한 계정이 곧바로 고권한으로 도약하는 취약점이라면 이야기가 달라집니다.</p>

<h3 id="2-권한-상승-최소-역할이-enterprise-read-only">2) 권한 상승: 최소 역할이 Enterprise Read Only</h3>

<p>Arista가 최소 역할을 <strong>Enterprise Read Only</strong>라고 못 박았다는 게 중요합니다. “관리자는 아니고 보기만 하는 계정” 정도로 생각하기 쉬운 역할이, 취약점 체인에서는 ‘공격 버튼을 누를 수 있는 자격’이 됩니다.<sup id="fnref:1:7" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이 유형은 내부자 위협뿐 아니라 “계정 하나만 새면 끝”이라는 성격을 띱니다. 공격자는 굳이 operator 계정을 얻을 필요가 없습니다.</p>

<h3 id="3-내부-기능-접근-orchestrator가-제공하는-기능의-범위-자체가-넓다">3) 내부 기능 접근: Orchestrator가 제공하는 기능의 범위 자체가 넓다</h3>

<p>Arista 문서 기준으로 Orchestrator Portal은 네트워크/디바이스 구성을 관리하고 상태를 질의합니다. API도 JSON-RPC/REST-like로 동일 기능을 호출 가능하며, 모두 HTTP POST로 들어온다고 명시돼 있습니다.<sup id="fnref:3:2" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>이 말은 곧 “권한이 올라간 계정이 할 수 있는 일의 반경(radius of blast)”이 매우 넓다는 뜻입니다. 오케스트레이터가 가진 힘은 개별 장비 쉘 접근보다 덜 위험한 게 아니라, 종종 더 위험합니다.</p>

<ul>
  <li>설정 변경은 여러 사이트/여러 Edge에 동시 전파가 가능합니다.</li>
  <li>문제를 숨기기 위한 상태/모니터링 교란까지 같은 면에서 일어납니다.</li>
  <li>운영자가 평소 하는 변경(정상 변경)과 공격자의 변경(악성 변경)이 같은 채널을 타므로, 탐지가 어려워집니다.</li>
</ul>

<h3 id="4-host-impact-단순-권한-상승을-넘는-침해-가능성">4) host impact: 단순 권한 상승을 넘는 침해 가능성</h3>

<p>Advisory 0199는 “impact the VCO host”를 직접 언급합니다. 또 방어 권고에 “backdoor daemons”, “webshells” 모니터링이 들어가 있습니다. 이 조합은 실무적으로 이렇게 해석합니다.</p>

<ul>
  <li>공격 결과가 포털 내 권한 상승에 그치지 않고, OS/프로세스 레벨 지속성(persistence)까지 이어질 수 있다는 것을 vendor가 염두에 두고 있다.</li>
</ul>

<p>물론 Arista는 “어떻게 host까지 가는지”를 상세히 공개하지는 않았습니다. 하지만 운영자가 해야 할 일은 “상세가 없으니 기다리자”가 아니라, host 침해 가정 하에 로그/포렌식 보존과 네트워크 egress 관측을 우선순위에 올리는 것입니다.<sup id="fnref:1:8" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<hr />

<h2 id="우선순위-1-web-interface-노출면-축소internet에서-치우기">우선순위 1: web interface 노출면 축소(Internet에서 치우기)</h2>

<p>Advisory 0199는 공격 성립 조건을 “VCO web interface에 대한 네트워크 접근”이라고 매우 노골적으로 적었습니다. 이 문장 하나로, 네트워크 팀이 당장 할 수 있는 방어의 80%가 결정됩니다.<sup id="fnref:1:9" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>여기서 말하는 노출면 축소는 단순히 “WAF 붙이자”가 아닙니다. 관리 plane의 접근 경로를 설계 수준에서 줄이는 게 핵심입니다.</p>

<h3 id="1-관리-plane-접속-경로를-한-군데로-접기">1) 관리 plane 접속 경로를 한 군데로 접기</h3>

<p>가장 현실적인 패턴은 아래 중 하나입니다.</p>

<ul>
  <li>사내 VPN(또는 ZTNA) → jump host → VCO web UI</li>
  <li>사내 VPN(또는 ZTNA) → 내부 L7 proxy → VCO web UI</li>
</ul>

<p>핵심은 “브라우저로 직접 VCO에 닿는 네트워크”가 관리망으로만 제한되게 만드는 것입니다. Arista도 mitigation 첫 줄이 “trusted administrative networks로 web interface 접근 제한”입니다.<sup id="fnref:1:10" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이 단계에서 중요한 건 allowlist 사고방식입니다.</p>

<ul>
  <li>0.0.0.0/0에서 관리 UI를 볼 수 있는 상태는, 장기적으로는 결국 뚫립니다.</li>
  <li>‘필요할 때만 열고 닫는다’는 운영은 사람 실수로 무너집니다.</li>
</ul>

<h3 id="2-bastion-orchestrator로-공용사설-orchestrator-경계를-나누는-설계">2) Bastion Orchestrator로 공용/사설 Orchestrator 경계를 나누는 설계</h3>

<p>VeloCloud 쪽 문서에는 Bastion Orchestrator(Public Orchestrator) 개념이 들어 있습니다. 문서가 말하는 취지는 명확합니다.</p>

<ul>
  <li>Orchestrator가 고객과 네트워크에 대한 민감 정보를 저장/내보내며(API 포함), 이를 외부 공격으로부터 보호하고 API 접근을 제한하기 위해 Internet-facing DMZ에 Bastion Orchestrator를 둘 수 있다.</li>
  <li>목적은 Edge의 staging/activation이다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<p>이건 이번 취약점 대응에서도 그대로 적용됩니다.</p>

<ul>
  <li>Edge activation 같은 이유로 “외부에서 Orchestrator로 들어와야만 하는” 요구가 있다면, 그 요구를 Production Orchestrator가 직접 받게 두지 않는 쪽이 낫습니다.</li>
  <li>Public(DMZ)에는 기능이 제한된 Bastion, Private에는 Production을 두고, 둘 사이에 최소한의 연결만 허용하는 방식이 공격면을 줄입니다.</li>
</ul>

<p>문서는 Bastion/Production 워크플로우를 분리하고, 제한사항도 적습니다. 예를 들어 Bastion 구성에서 stage할 수 있는 Operator Super user 계정은 1개이며, 이 계정은 emergency 용도로 Bastion 설정 페이지만 접근 가능한 식으로 설명돼 있습니다. 이런 제약은 불편하지만, 보안 관점에서는 의도적으로 blast radius를 줄이는 설계입니다.<sup id="fnref:4:1" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<p>추가로, 문서는 Bastion이 stateless이고 Production에서 지시를 받는다고 설명하면서 Bastion에는 DR을 지원하지 않는다고도 밝힙니다. DMZ에 놓는 시스템이 ‘상태를 덜 갖는’ 방향인 것도 방어에 유리합니다.<sup id="fnref:4:2" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<h3 id="3-reverse-proxynginx-앞단의-오해-앞에-하나-더-둔다고-해결되진-않는다">3) reverse proxy/NGINX 앞단의 오해: “앞에 하나 더 둔다고 해결되진 않는다”</h3>

<p>Arista Orchestrator 문서에는 Portal 프로세스가 “NGINX downstream의 internal HTTP server”로 동작한다고 적혀 있습니다. 즉, 제품 자체가 이미 NGINX 계층을 갖고 있고, 그 뒤에 Portal이 있습니다.<sup id="fnref:3:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<p>여기서 흔히 나오는 오해는 이겁니다.</p>

<ul>
  <li>“그럼 NGINX 앞에 또 NGINX/WAF를 붙이면 안전해지지 않나?”</li>
</ul>

<p>앞단 proxy는 도움이 됩니다. 다만 ‘취약점이 SQLi’이고, 게다가 vendor가 “설정으로 노출을 막는 방법은 없다”고까지 말하는 상황에서는, 앞단 proxy가 취약점을 없애주지 않습니다. 앞단 proxy가 줄일 수 있는 건 대략 아래입니다.</p>

<ul>
  <li>접근 주체를 강하게 제한(allowlist, VPN 종단)</li>
  <li>rate limit / anomaly 기반 차단(특히 짧은 시간 burst)</li>
  <li>표준 로그 통합(탐지 효율)</li>
</ul>

<p>즉, proxy는 완화책이고 본질 대응은 patch입니다.</p>

<h3 id="4-egress-통제는-사후-방어가-아니라-침해-가정-방어다">4) egress 통제는 ‘사후 방어’가 아니라 ‘침해 가정 방어’다</h3>

<p>Arista는 defense-in-depth 목록에 “unexpected outbound network activity 모니터링”, “필요 없는 outbound 포트 차단 고려”를 넣었습니다. 이 문장은 오케스트레이터가 뚫렸을 때, 공격자가 외부 C2로 나가거나 데이터 유출을 시도할 가능성을 vendor도 전제하고 있다는 뜻입니다.<sup id="fnref:1:11" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>내 경우 SD-WAN Orchestrator 같은 관리 plane 서버는 egress를 기본 deny로 놓기 어렵더라도, 최소한 이 원칙을 잡습니다.</p>

<ul>
  <li>제품 문서상 필요한 외부 목적지(업데이트, 인증, SMS, GeoIP 등)가 뭐든 간에, 그 외의 outbound는 관측 가능해야 하고, 차단은 단계적으로 가능해야 한다.</li>
</ul>

<p>(Arista 문서에도 외부 서비스 연동 예시가 꽤 많이 등장합니다. 운영 환경마다 egress 요구는 달라질 수 있습니다. 그래서 일괄 deny를 조언하기보다는 “관측 → 축소” 순서가 맞습니다.)<sup id="fnref:3:4" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<h3 id="5-방화벽-정책-예시nftables-관리-ui는-관리망만">5) 방화벽 정책 예시(nftables): ‘관리 UI는 관리망만’</h3>

<p>아래는 Linux 기반 환경에서 흔히 쓰는 nftables 예시입니다. 실제 포트/프로토콜은 환경에 맞게 조정해야 하고, 핵심은 ‘UI 포트는 관리망 allowlist’입니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ubuntu 24.04 기준 예시</span>
<span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> nftables

<span class="c"># /etc/nftables.conf (예시)</span>
<span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">' | sudo tee /etc/nftables.conf
flush ruleset

table inet filter {
  set admin_nets {
    type ipv4_addr
    flags interval
    elements = { 10.10.0.0/16, 192.168.100.0/24 }
  }

  chain input {
    type filter hook input priority 0;
    policy drop;

    iif lo accept

    # established/related
    ct state established,related accept

    # SSH (관리망만)
    tcp dport 22 ip saddr @admin_nets accept

    # VCO Web UI / API (관리망만)
    tcp dport 443 ip saddr @admin_nets accept

    # ICMP는 운영 정책에 맞게
    ip protocol icmp accept

    # 나머지는 드랍
  }
}
</span><span class="no">EOF

</span><span class="nb">sudo </span>systemctl <span class="nb">enable</span> <span class="nt">--now</span> nftables
<span class="nb">sudo </span>nft <span class="nt">-f</span> /etc/nftables.conf
<span class="nb">sudo </span>nft list ruleset
</code></pre></div></div>

<p>이 정책의 의도는 단순합니다.</p>

<ul>
  <li>취약점이 무엇이든, 관리 plane UI/API가 인터넷에 열려 있는 한 스캐닝/브루트포스/계정탈취의 압력을 계속 받습니다.</li>
  <li>반대로 UI를 관리망으로 접으면, 취약점 악용은 ‘관리망 침해’라는 더 큰 선행 조건을 요구하게 됩니다.</li>
</ul>

<p>그리고 이번 건은 Arista가 공격 성립 조건으로 “web interface 네트워크 접근”을 직접 적었기 때문에, 이 통제가 특히 직접적인 효과를 냅니다.<sup id="fnref:1:12" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<hr />

<h2 id="우선순위-2-로그-기반-탐지이미-들어왔는지-먼저-확인">우선순위 2: 로그 기반 탐지(이미 들어왔는지 먼저 확인)</h2>

<p>패치가 최우선이지만, “활발 악용”이 걸린 시점에서는 패치만으로 끝내면 운영 리스크가 남습니다. 이미 침해됐는지 확인하지 않으면, 고쳐도 공격자가 남긴 계정/지속성 때문에 다시 터질 수 있습니다.</p>

<p>Advisory 0199는 IoC 섹션에서 굉장히 구체적인 가이드를 줍니다.</p>

<ul>
  <li>VCO web access log를 보라.</li>
  <li>unusual URL-like path components, encoded characters, local/internal service reference, high request rate를 조사하라.</li>
  <li>공격에 관측된 IP 2개를 차단하고, 로그에 있었는지 확인하라.</li>
  <li>침해가 의심되면 remediation 전에 web access log / backend app log / system log / database log / filesystem timestamp를 보존하라.<sup id="fnref:1:13" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>이 섹션이 좋은 이유는, vendor가 취약점 세부를 공개하지 않아도 탐지 관측점을 제공한다는 점입니다.</p>

<h3 id="1-arista가-공개한-관측-공격-ip">1) Arista가 공개한 관측 공격 IP</h3>

<p>Advisory 0199에 실린 공격 IP는 아래 2개입니다.</p>

<ul>
  <li><strong>74.82.197.86 / 38.54.50.151</strong></li>
</ul>

<p>이 2개는 “이 IP가 보이면 100% 침해” 같은 단정의 근거가 되진 않습니다. 다만 triage에서는 매우 쓸모가 있습니다.</p>

<ul>
  <li>이 IP가 내 VCO access log에 있으면, 최소한 사건 대응 티켓은 즉시 P1로 격상하는 편이 맞습니다.</li>
  <li>이 IP가 없다고 해서 안전한 것도 아닙니다. 공격자가 더 많을 수 있기 때문입니다.</li>
</ul>

<p>하지만 ‘활발 악용’ + ‘관측 IP 제공’ 조합은, 로그 확인의 ROI가 높다는 뜻입니다.<sup id="fnref:1:14" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<h3 id="2-access-log에서-보는-신호-패턴-기반--속도-기반">2) access log에서 보는 신호: 패턴 기반 + 속도 기반</h3>

<p>Advisory 0199가 제시한 힌트는 2종류입니다.</p>

<ul>
  <li>패턴 기반
    <ul>
      <li>encoded characters(특히 과한 percent-encoding)</li>
      <li>local/internal 서비스 참조</li>
      <li>URL path가 정상 UI 동작에서 보기 힘든 모양</li>
    </ul>
  </li>
  <li>속도 기반
    <ul>
      <li>high request rates</li>
    </ul>
  </li>
</ul>

<p>SQLi라고 해서 꼭 <code class="language-plaintext highlighter-rouge">' OR 1=1</code> 같은 교과서 문자열이 찍히는 건 아닙니다. 오히려 실제 공격 트래픽은 제품 내부 경로를 찌르기 위해 path/encoding이 비정상인 경우가 많고, 공격자 자동화는 request rate를 밀어 올립니다.</p>

<h3 id="3-실제로-돌려서-쓰는-로그-스캐너python-311">3) 실제로 돌려서 쓰는 로그 스캐너(Python 3.11)</h3>

<p>VCO가 어떤 포맷으로 access log를 남기는지는 배치마다 다를 수 있습니다(내장 NGINX, 외부 LB/proxy 등). 그래서 아래 코드는 NGINX combined log 형식을 기본 가정으로 하고, 파싱에 실패하면 “원문 라인 그대로”라도 의심 신호를 찾게 만들었습니다.</p>

<ul>
  <li>의존성: Python 표준 라이브러리만 사용</li>
  <li>입력: access.log</li>
  <li>출력: 의심 라인과 간단한 요약</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 <span class="nt">--version</span>
<span class="c"># Python 3.11+ 권장</span>

<span class="nb">cat</span> <span class="o">&gt;</span> vco_access_triage.py <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">PY</span><span class="sh">'
import re
import sys
from collections import defaultdict, deque
from datetime import datetime

# NGINX combined log 대략 형태:
# 1.2.3.4 - - [25/Oct/2026:12:34:56 +0900] "POST /path HTTP/1.1" 200 123 "-" "UA"
LOG_RE = re.compile(
    r'^(?P&lt;ip&gt;</span><span class="se">\S</span><span class="sh">+)</span><span class="se">\s</span><span class="sh">+</span><span class="se">\S</span><span class="sh">+</span><span class="se">\s</span><span class="sh">+</span><span class="se">\S</span><span class="sh">+</span><span class="se">\s</span><span class="sh">+</span><span class="se">\[</span><span class="sh">(?P&lt;ts&gt;[^</span><span class="se">\]</span><span class="sh">]+)</span><span class="se">\]\s</span><span class="sh">+'
    r'"(?P&lt;method&gt;[A-Z]+)</span><span class="se">\s</span><span class="sh">+(?P&lt;path&gt;</span><span class="se">\S</span><span class="sh">+)</span><span class="se">\s</span><span class="sh">+(?P&lt;proto&gt;[^"]+)"</span><span class="se">\s</span><span class="sh">+'
    r'(?P&lt;status&gt;</span><span class="se">\d</span><span class="sh">{3})</span><span class="se">\s</span><span class="sh">+(?P&lt;size&gt;</span><span class="se">\S</span><span class="sh">+)</span><span class="se">\s</span><span class="sh">+"(?P&lt;ref&gt;[^"]*)"</span><span class="se">\s</span><span class="sh">+"(?P&lt;ua&gt;[^"]*)"'
)

# Arista Advisory 0199에서 제공한 관측 공격 IP
ARISTA_OBSERVED_IPS = {"74.82.197.86", "38.54.50.151"}

SUSPICIOUS_SUBSTRINGS = [
    "%2f", "%2F", "%5c", "%5C",  # /, </span><span class="se">\\</span><span class="sh"> 인코딩
    "%252f", "%255c",              # 이중 인코딩 흔적
    "..%2f", "..%2F", "../", "..</span><span class="se">\\</span><span class="sh">",  # traversal
    "localhost", "127.0.0.1", "::1",
    "169.254.",  # link-local
    "http://", "https://",
]

# 너무 단정적인 SQLi 키워드는 오탐이 많아서 '가중치'로만 처리
SQLI_HINTS = ["'", '"', "--", "/*", "*/", ";", " or ", " and ", " union ", " select ", " information_schema "]

# request rate: 60초 window 내 120회 초과면 의심(환경에 맞게 조정)
WINDOW_SECS = 60
RATE_THRESHOLD = 120

# ts 파싱: [25/Oct/2026:12:34:56 +0900]
def parse_ts(ts: str):
    # timezone offset은 여기서는 버리고 naive로 처리
    # 포렌식용으로는 원문 보존이 더 중요
    return datetime.strptime(ts.split()[0], "%d/%b/%Y:%H:%M:%S")

def score_path(path: str):
    p = path.lower()
    score = 0
    reasons = []

    for s in SUSPICIOUS_SUBSTRINGS:
        if s.lower() in p:
            score += 3
            reasons.append(f"substr:{s}")

    # percent-encoding 비율이 과도하면 가중치
    pct = p.count("%")
    if pct &gt;= 6:
        score += 2
        reasons.append("many-percent")

    # SQLi 힌트: 단독으론 약하므로 가중치 낮게
    hint_hits = 0
    for h in SQLI_HINTS:
        if h in p:
            hint_hits += 1
    if hint_hits &gt;= 3:
        score += 2
        reasons.append(f"sqli-hints:{hint_hits}")

    return score, reasons

def main():
    if len(sys.argv) != 2:
        print("usage: python3 vco_access_triage.py /path/to/access.log", file=sys.stderr)
        sys.exit(2)

    path = sys.argv[1]

    # IP별 요청 시각 window
    windows = defaultdict(deque)  # ip -&gt; deque[datetime]

    suspicious = []
    totals = 0
    parse_fail = 0

    with open(path, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.rstrip("</span><span class="se">\n</span><span class="sh">")
            totals += 1

            m = LOG_RE.match(line)
            if not m:
                parse_fail += 1
                # 파싱 실패 라인에서도 힌트만 탐지
                lower = line.lower()
                if any(s.lower() in lower for s in SUSPICIOUS_SUBSTRINGS):
                    suspicious.append(("?", "?", "?", 5, ["unparsed-but-has-suspicious-substr"], line))
                continue

            ip = m.group("ip")
            ts_raw = m.group("ts")
            method = m.group("method")
            req_path = m.group("path")
            status = m.group("status")

            try:
                ts = parse_ts(ts_raw)
            except Exception:
                ts = None

            score, reasons = score_path(req_path)

            # Arista 관측 IP는 바로 가중치
            if ip in ARISTA_OBSERVED_IPS:
                score += 6
                reasons.append("arista-observed-ip")

            # request rate 의심
            if ts is not None:
                dq = windows[ip]
                dq.append(ts)
                cutoff = ts.timestamp() - WINDOW_SECS
                while dq and dq[0].timestamp() &lt; cutoff:
                    dq.popleft()
                if len(dq) &gt;= RATE_THRESHOLD:
                    score += 4
                    reasons.append(f"high-rate:{len(dq)}/{WINDOW_SECS}s")

            if score &gt;= 6:
                suspicious.append((ip, ts_raw, f"{method} {req_path}", score, reasons, line))

    suspicious.sort(key=lambda x: x[3], reverse=True)

    print(f"total_lines={totals} parse_fail={parse_fail} suspicious={len(suspicious)}")
    print("top suspicious lines:")

    for ip, ts_raw, req, score, reasons, raw in suspicious[:50]:
        print("-")
        print(f"  ip: {ip}")
        print(f"  ts: {ts_raw}")
        print(f"  req: {req}")
        print(f"  score: {score}")
        print(f"  reasons: {', '.join(reasons)}")
        print(f"  raw: {raw}")

if __name__ == "__main__":
    main()
</span><span class="no">PY

</span>python3 vco_access_triage.py ./access.log
</code></pre></div></div>

<p>예상 출력 형태는 다음과 같습니다(샘플).</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>total_lines=182934 parse_fail=0 suspicious=3
top suspicious lines:
-
  ip: 74.82.197.86
  ts: 25/Oct/2026:03:12:01 +0900
  req: POST /...%252f... HTTP/1.1
  score: 14
  reasons: substr:%252f, many-percent, arista-observed-ip
  raw: 74.82.197.86 - - [25/Oct/2026:03:12:01 +0900] "POST /...%252f... HTTP/1.1" 200 123 "-" "Mozilla/5.0"
</code></pre></div></div>

<p>이 스크립트는 “침해 여부 판정기”가 아닙니다. triage를 자동화해 사람이 봐야 할 로그의 양을 줄이는 도구입니다. vendor가 말한 힌트(encoding/path/internal reference/high rate)를 그대로 점수화했고, 관측 IP 2개는 우선순위만 올려 둔 형태입니다.<sup id="fnref:1:15" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<h3 id="4-로그-보존-patch-전에-할-일도-있다">4) 로그 보존: patch 전에 할 일도 있다</h3>

<p>침해가 의심되면 “고치기 전에” 보존해야 하는 로그가 있습니다. Arista는 web access log, backend application log, system log, database log, 파일시스템 timestamp까지 구체적으로 나열합니다. 내가 좋아하는 문장입니다. vendor가 ‘증거를 남기라’고 직접 말하는 건 흔치 않습니다.<sup id="fnref:1:16" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>운영적으로는 다음 리스크 때문에 선보존이 필요합니다.</p>

<ul>
  <li>업그레이드/재시작 과정에서 volatile 로그가 날아갈 수 있습니다.</li>
  <li>공격자가 이미 webshell/백도어를 심었다면, patch 후에도 흔적은 남습니다. 다만 시간 정보가 바뀌면 조사 난도가 올라갑니다.</li>
</ul>

<hr />

<h2 id="패치-우선순위-버전-고정이-명확할-때는-지금이-답이다">패치 우선순위: 버전 고정이 명확할 때는 ‘지금’이 답이다</h2>

<p>이번 건은 “패치 고정 버전이 명확하다”는 점에서 대응 가치가 큽니다.</p>

<ul>
  <li>fixed: 5.2.3.18 / 6.1.3.10 / 6.4.2.12<sup id="fnref:1:17" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>Arista는 해결책을 “가능한 한 빨리 fixed release로 업그레이드”라고만 단순하게 말합니다. 그리고 지원되지 않는 release train이면 TAC에 연락해서 업그레이드 옵션을 논의하라고 합니다.<sup id="fnref:1:18" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>여기서 실무적으로는 다음과 같이 정리하는 편이 맞습니다.</p>

<p>1) 인터넷/광역망에서 VCO web interface로 접근 가능한지부터 확인</p>
<ul>
  <li>Arista 표현 그대로 “성공 공격은 web interface 네트워크 접근을 요구”합니다. 그게 된다면, 지금 이 순간에도 공격 표면이 열려 있는 셈입니다.<sup id="fnref:1:19" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>2) 버전 확인 후 즉시 업그레이드 창을 잡기</p>
<ul>
  <li>버전이 영향 범위면, 우선순위를 바꿀 이유가 없습니다.</li>
</ul>

<p>3) 업그레이드 전/후로 최소한의 침해 징후 확인</p>
<ul>
  <li>관측 IP가 있는지, access log에서 encoding/path 이상이 있는지.</li>
</ul>

<p>4) 업그레이드가 바로 안 되면 defense-in-depth를 ‘체크리스트’로 실행</p>
<ul>
  <li>Arista가 제시한 항목은 하나씩 그대로 실행 가능한 작업입니다(관리망 제한, 악성 IP 모니터링, outbound 관측/차단 검토, webshell/daemon 모니터링, 관리자 활동 리뷰).<sup id="fnref:1:20" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>또 하나의 사실: Hosted(전용 포함) VCO는 영향을 받았지만 이미 패치됐다고 공지에 적혀 있습니다. on-prem 운영자가 상대적으로 더 위험한 구간에 남는 구조입니다.<sup id="fnref:1:21" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<hr />

<h2 id="반론과-회의론-인증-필요면-cvss가-과한-것-아닌가">반론과 회의론: “인증 필요면 CVSS가 과한 것 아닌가?”</h2>

<p>실제 현장에서 가장 많이 나오는 반론은 이겁니다.</p>

<ul>
  <li>“인증이 필요한데 CVSS 9.9가 맞나?”</li>
</ul>

<p>나는 CVSS 점수 자체를 논쟁할 필요가 없다고 봅니다. vendor가 이 사건에서 이미 중요한 사실을 다 깔아줬습니다.</p>

<ul>
  <li>actively exploited</li>
  <li>최소 권한(Enterprise Read Only)으로 operator privilege 획득</li>
  <li>내부 기능 접근 + host impact</li>
</ul>

<p>즉, 이 취약점은 “인증 필요”라는 단점(공격 난이도 증가)을 “권한 상승 + 관리 plane 장악”으로 상쇄하고 남습니다.<sup id="fnref:1:22" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>또 다른 회의론도 있습니다.</p>

<ul>
  <li>“우린 관리 UI를 인터넷에 안 열어뒀다.”</li>
</ul>

<p>이 경우에도 끝이 아닙니다. Arista가 말한 성공 조건은 “네트워크 접근 + 인증 세션”입니다. 인터넷이 아니라 사내망에서 노려도 조건은 충족됩니다. SD-WAN 운영 환경에서는 지사/협력사/운영 대행/관제망 등 네트워크 경계가 단순하지 않은 경우가 흔합니다. 관리 UI를 관리망으로 접는 것은 기본이고, 그 관리망 자체가 공격 경계임을 다시 설계해야 합니다.</p>

<p>마지막으로 가장 위험한 오해는 이겁니다.</p>

<ul>
  <li>“제품 설정으로 어느 정도 막을 수 있지 않을까?”</li>
</ul>

<p>Advisory 0199는 “There is no configuration that can prevent the exposure”라고 말합니다. 설정 knob로 우회하는 순간, 대응은 늦어집니다. 이번 건은 upgrade가 답입니다.<sup id="fnref:1:23" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<hr />

<h2 id="앞으로-지켜볼-것-추가-ioc-침해-후-행동-그리고-재발-패턴">앞으로 지켜볼 것: 추가 IoC, 침해 후 행동, 그리고 재발 패턴</h2>

<p>Advisory 0199는 revision 1.0(초기 공개)이고, IoC도 IP 2개와 로그 관측 포인트 정도만 제공합니다.<sup id="fnref:1:24" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>앞으로 더 나올 수 있는(그리고 운영팀이 기다려야 하는) 정보는 보통 이런 것들입니다.</p>

<ul>
  <li>더 넓은 IoC: URL path 패턴, User-Agent, 세션/파라미터 형태</li>
  <li>침해 후 행동: 계정 생성/권한 변경 흔적, 설정 변경 흔적, 특정 기능 호출 흔적</li>
  <li>host impact의 구체화: webshell 위치, 프로세스/서비스 이름, 파일 해시</li>
</ul>

<p>다만 이런 정보는 늦게 나오거나 끝까지 공개되지 않을 수도 있습니다. 그래서 나는 “추가 정보 기다리기”를 대응 플랜에 넣지 않고, 이미 나온 사실만으로 우선순위를 정합니다.</p>

<ul>
  <li>관리 UI 노출면을 접는다.</li>
  <li>로그에서 이상을 찾는다.</li>
  <li>고정 버전으로 올린다.</li>
</ul>

<p>관리 plane 보안은 결국 trust boundary 설계 문제로 귀결됩니다. 예전에 LLM guardrail 글에서 trust boundary를 설계하지 않으면 기술로 막을 수 없다고 썼는데, 관리 UI도 똑같습니다.</p>

<ul>
  <li><a href="https://daewooki.github.io/posts/trust-boundary-2026-6-llm-guardrail-1/">프롬프트 인젝션은 신뢰 경계를 설계하는 문제다: LLM Guardrail 심층 분석</a></li>
</ul>

<p>또, web/API 노출면을 줄이는 사고방식은 NGINX Control API/njs 리스크를 다룰 때와 같은 결입니다. “기능을 끄는 것”보다 “누가 어디서 접근할 수 있는지”가 먼저입니다.</p>

<ul>
  <li><a href="https://daewooki.github.io/posts/nginx-control-api-njs-security/">NGINX 1.31.5: Control API와 njs 리스크가 만나는 지점</a></li>
</ul>

<hr />

<h2 id="정리-노출면-축소--로그-기반-탐지가-먼저다">정리: <strong>노출면 축소 + 로그 기반 탐지</strong>가 먼저다</h2>

<p>CVE-2026-114625는 SQL injection이라는 라벨보다, “최저 권한 인증 사용자 → operator privilege → 내부 기능 접근 → host impact 가능”이라는 형태가 본질입니다. 오케스트레이터 계층은 SD-WAN에서 관리 plane의 권한과 데이터를 한 곳에 모으기 때문에, 이 체인이 완성되면 네트워크 운영권 자체가 넘어갈 수 있습니다.<sup id="fnref:1:25" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<p>이번 Arista Advisory 0199는 대응 방향도 같이 줍니다.</p>

<ul>
  <li>web interface 접근을 trusted administrative networks로 제한</li>
  <li>access log 기반으로 encoding/path/internal reference/high rate를 조사</li>
  <li>관측 IP 2개 차단/로그 확인</li>
  <li>필요하면 로그/타임스탬프를 보존한 뒤 remediation<sup id="fnref:1:26" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
</ul>

<p>패치 고정 버전이 분명하니(5.2.3.18/6.1.3.10/6.4.2.12), 더 복잡한 판단을 할 이유가 없습니다. 네트워크 관점에서는 관리 plane의 노출면을 먼저 접고, 보안 관점에서는 로그로 이미 들어왔는지부터 확인하는 순서가 맞습니다.</p>

<hr />

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://www.arista.com/en/support/advisories-notices/security-advisory/24838-security-advisory-0199">Arista Security Advisory 0199</a></li>
  <li><a href="https://www.cyber.gc.ca/en/alerts-advisories/arista-networks-security-advisory-av26-1080">Canadian Centre for Cyber Security AV26-1080 Update 1</a></li>
  <li><a href="https://www.cve.org/CVERecord?id=CVE-2026-114625">CVE Record: CVE-2026-114625</a></li>
  <li><a href="https://www.arista.com/assets/data/pdf/qsg/VC-SD-WAN-5.2-Configuration-Guide.pdf">VeloCloud SD-WAN Bastion Orchestrator Configuration Guide (Version 5.2)</a></li>
  <li><a href="https://www.arista.com/en/orchestrator-guide-vc-7-0/sd-wan-7-0-velocloud-orchestrator-deployment-and-monitoring-guide">VeloCloud Orchestrator Deployment and Monitoring Guide (SD-WAN 7.0)</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://www.arista.com/en/support/advisories-notices/security-advisory/24838-security-advisory-0199">https://www.arista.com/en/support/advisories-notices/security-advisory/24838-security-advisory-0199</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:1:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:1:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:1:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:1:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:1:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a> <a href="#fnref:1:9" class="reversefootnote" role="doc-backlink">&#8617;<sup>10</sup></a> <a href="#fnref:1:10" class="reversefootnote" role="doc-backlink">&#8617;<sup>11</sup></a> <a href="#fnref:1:11" class="reversefootnote" role="doc-backlink">&#8617;<sup>12</sup></a> <a href="#fnref:1:12" class="reversefootnote" role="doc-backlink">&#8617;<sup>13</sup></a> <a href="#fnref:1:13" class="reversefootnote" role="doc-backlink">&#8617;<sup>14</sup></a> <a href="#fnref:1:14" class="reversefootnote" role="doc-backlink">&#8617;<sup>15</sup></a> <a href="#fnref:1:15" class="reversefootnote" role="doc-backlink">&#8617;<sup>16</sup></a> <a href="#fnref:1:16" class="reversefootnote" role="doc-backlink">&#8617;<sup>17</sup></a> <a href="#fnref:1:17" class="reversefootnote" role="doc-backlink">&#8617;<sup>18</sup></a> <a href="#fnref:1:18" class="reversefootnote" role="doc-backlink">&#8617;<sup>19</sup></a> <a href="#fnref:1:19" class="reversefootnote" role="doc-backlink">&#8617;<sup>20</sup></a> <a href="#fnref:1:20" class="reversefootnote" role="doc-backlink">&#8617;<sup>21</sup></a> <a href="#fnref:1:21" class="reversefootnote" role="doc-backlink">&#8617;<sup>22</sup></a> <a href="#fnref:1:22" class="reversefootnote" role="doc-backlink">&#8617;<sup>23</sup></a> <a href="#fnref:1:23" class="reversefootnote" role="doc-backlink">&#8617;<sup>24</sup></a> <a href="#fnref:1:24" class="reversefootnote" role="doc-backlink">&#8617;<sup>25</sup></a> <a href="#fnref:1:25" class="reversefootnote" role="doc-backlink">&#8617;<sup>26</sup></a> <a href="#fnref:1:26" class="reversefootnote" role="doc-backlink">&#8617;<sup>27</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://www.cyber.gc.ca/en/alerts-advisories/arista-networks-security-advisory-av26-1080">https://www.cyber.gc.ca/en/alerts-advisories/arista-networks-security-advisory-av26-1080</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://www.arista.com/en/orchestrator-guide-vc-7-0/sd-wan-7-0-velocloud-orchestrator-deployment-and-monitoring-guide">https://www.arista.com/en/orchestrator-guide-vc-7-0/sd-wan-7-0-velocloud-orchestrator-deployment-and-monitoring-guide</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:3:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:3:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:3:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://www.arista.com/assets/data/pdf/qsg/VC-SD-WAN-5.2-Configuration-Guide.pdf">https://www.arista.com/assets/data/pdf/qsg/VC-SD-WAN-5.2-Configuration-Guide.pdf</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:4:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:4:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="News" /><category term="Networking" /><category term="arista" /><category term="velocloud" /><category term="sd-wan" /><category term="sqli" /><category term="cve-2026-114625" /><category term="incident-response" /><summary type="html"><![CDATA[Arista Advisory 0199의 VCO On-Prem SQLi는 ‘활발 악용’이 확인됐고 고정 버전이 명확합니다. 웹 노출면 축소와 로그 기반 탐지를 우선순위로 재정렬합니다.]]></summary></entry><entry><title type="html">Linux 커널 7.3 메인라인 이후 GA 롤아웃 검증 체크리스트</title><link href="https://joyuno.github.io/blog/2026/10/24/linux-kernel-73-ga-rollout-validation/" rel="alternate" type="text/html" title="Linux 커널 7.3 메인라인 이후 GA 롤아웃 검증 체크리스트" /><published>2026-10-24T12:40:33+09:00</published><updated>2026-10-24T12:40:33+09:00</updated><id>https://joyuno.github.io/blog/2026/10/24/linux-kernel-73-ga-rollout-validation</id><content type="html" xml:base="https://joyuno.github.io/blog/2026/10/24/linux-kernel-73-ga-rollout-validation/"><![CDATA[<h2 id="2026-10-18-linux-73-메인라인-릴리스-사실관계와-운영상-의미">2026-10-18 Linux 7.3 메인라인 릴리스: 사실관계와 운영상 의미</h2>

<p>Linux 커널 7.3은 kernel.org 기준으로 <strong>2026-10-18</strong>에 mainline로 릴리스되어 배포 아티팩트(tarball/patch/PGP signature)가 올라왔습니다.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> 이 타임스탬프가 운영 관점에서 중요한 이유는 단순히 “버전이 올라갔다”가 아니라, 지난 7.3-rcN 기간에 하던 검증이 ‘개발 사이클의 리그레션 탐지’에 치우쳐 있었다면 이제는 ‘내 조직이 실제로 배포 가능한 형태의 아티팩트’로 커널을 취급해야 하기 때문입니다.</p>

<p>커널 릴리스 모델을 kernel.org는 Prepatch(-rc), Mainline, Stable, Longterm으로 구분합니다. -rc는 “새 기능이 들어가 있고 테스트가 필요해서 enthusiasts/개발자 중심”이라고 못 박고, mainline이 릴리스된 뒤에는 그 mainline을 “stable로 간주”한다고 설명합니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> 여기서 stable은 우리가 흔히 말하는 “운영용으로 안전하다”의 의미가 아니라, “해당 mainline 버전 라인에 대해 bugfix가 backport되는 단계로 들어간다”에 가깝습니다.</p>

<p>그래서 커널을 제품 장애와 1:1로 연결해서 보는 조직(스토리지 어플라이언스, 가상화 호스트, GPU 워크로드 노드, HCI, Telco edge 등)에서는 mainline=stable이라는 문장만으로는 의사결정을 내리면 안 됩니다. 내 기준에서는 이렇게 정리하는 편이 덜 위험합니다.</p>

<ul>
  <li>RC 단계: upstream이 리그레션을 잡도록 돕는 테스트(발견/보고/재현/바이섹션/패치 검증)</li>
  <li>GA 단계(내 조직): 롤백 가능하고, 관측 가능하고, 장애 반경이 통제되는 배포(캔리/링 배포/driver·storage 집중)</li>
</ul>

<p>Linux 7.3에서 언급된 변화 폭 자체도 운영 전환의 근거가 됩니다. LWN은 7.3이 18,765 non-merge commits, 3,021 developers, 807 first-time contributors로 “record-breaker”였다고 정리했고, 눈에 띄는 변화로 group-scheduling(대형 시스템 성능), failfs, fchroot(), BPF per-CPU data, sched_ext subscheduler 완성, VM working-set tracking, ntfs3 alternate data streams, SMB server 개선 등을 언급합니다.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> KernelNewbies 역시 sched_ext/cgroup scheduling, ext4/btrfs 성능, failfs+fchroot(), userfaultfd working set, FUSE io_uring, RWF_DONTCACHE, ksmbd(Time Machine) 등을 prominent feature로 올려 두었습니다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<p>이런 사이클에서는 “RC 때 테스트를 했으니 GA는 그냥 릴리스 버전으로 치환”이 성립하지 않습니다. RC는 기능/리그레션의 탐지이고, GA는 배포의 공학입니다.</p>

<h2 id="rc-회귀-테스트에서-ga-롤아웃-검증으로-바뀌는-것">RC 회귀 테스트에서 GA 롤아웃 검증으로 바뀌는 것</h2>

<p>RC 기반 테스트는 내가 예전에 정리한 <a href="https://daewooki.github.io/posts/linux-kernel-rc-regression-pipeline/">Linux 7.3 RC 회귀 테스트·드라이버 검증 자동화 설계</a>에서 다뤘던 영역입니다. 그 글이 “리그레션을 더 빨리 찾고 upstream에 더 잘 전달하는 파이프라인”에 가깝다면, 이번 글은 그 파이프라인의 출력물을 받아 “운영 배포 가능”으로 바꾸는 단계에 집중합니다.</p>

<p>RC와 GA의 핵심 차이는 ‘성공 기준’입니다.</p>

<ul>
  <li>RC 성공 기준: 새 커널이 이전 RC 대비 나빠지지 않았는가(리그레션 중심), upstream에 제출 가능한 재현/로그/바이섹션이 있는가</li>
  <li>GA 성공 기준: 내 서비스 SLO/SLA 관점에서 안전하게 점진 배포가 가능한가, 롤백이 자동화되어 있는가, driver/스토리지 관련 장애가 캔리 링에서 충분히 드러나도록 설계되어 있는가</li>
</ul>

<p>운영에서 커널 사고는 대체로 다음 경로로 터집니다.</p>

<p>1) 커널 코드 자체 버그
2) 커널-펌웨어 상호작용(특정 NVMe FW, NIC FW, RAID HBA FW, GPU FW)
3) out-of-tree 모듈(DKMS, 벤더 드라이버)과의 ABI/심볼/서명 문제
4) 부팅 경로(UEFI/secure boot/모듈 서명/initramfs) 문제
5) 성능 회귀(스케줄러/NUMA/메모리 reclaim/I/O 스케줄링)로 인한 사실상 장애</p>

<p>RC 테스트는 1)에 강하고, GA는 2)~5)까지를 “배포 시스템” 관점에서 같이 잡아야 합니다. kernel.org가 릴리스 모델을 time-based rolling으로 설명하고(-rc는 주간 단위로 나오고 최종 릴리스가 되면 다음 merge window로 넘어감)<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>, regzbot 같은 시스템으로 회귀를 추적하는 문화가 자리 잡은 것도 결국 이 규모의 변화에서 ‘사람이 수동으로 다 못 본다’는 전제가 깔려 있습니다.<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></p>

<p>GA 전환 시점에서 제일 흔한 착각은 “커널이 GA면 안정적이다”가 아니라 “테스트가 GA면 안정적이다”입니다. 커널은 mainline일 뿐이고, 안정성을 만드는 건 배포 설계와 관측 설계입니다.</p>

<h2 id="73에서-운영-검증-축을-다시-잡아야-하는-이유드라이버스토리지-중심">7.3에서 운영 검증 축을 다시 잡아야 하는 이유(드라이버·스토리지 중심)</h2>

<p>7.3의 기능 리스트를 전부 다 파고드는 건 목적이 아닙니다. 다만 운영 검증 관점에서는 “어느 축이 바뀌었는지”가 체크리스트의 우선순위를 정합니다.</p>

<h3 id="스케줄러대형-시스템-캔리에서-성능-회귀를-장애로-취급해야-하는-영역">스케줄러/대형 시스템: 캔리에서 성능 회귀를 장애로 취급해야 하는 영역</h3>

<p>7.3에는 대형 시스템 성능을 겨냥한 group-scheduling 작업과 sched_ext 관련 진전이 언급됩니다.<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> 이런 변화는 기능 장애보다 “tail latency 상승 → 큐 적체 → 타임아웃/재시도 폭증 → 스토리지/네트워크 증폭” 같은 2차 장애로 터지는 편입니다. 특히 스토리지 노드나 DB 노드에서는 커널 스케줄링 변화가 blk-mq/IO submission 패턴과 만나서 현상이 더 심해집니다.</p>

<p>그래서 GA 전환 체크리스트에서는, 성능은 벤치마크 점수로만 판단하지 말고 “장애 신호”로 넣어야 합니다. 예를 들면 p99 I/O latency, soft lockup 여부, scheduler 관련 WARN, runqueue imbalance 같은 지표를 롤백 트리거로 넣는 식입니다.</p>

<h3 id="파일시스템블록-계층-속도-개선이-곧-안정성-개선은-아니다">파일시스템/블록 계층: 속도 개선이 곧 안정성 개선은 아니다</h3>

<p>KernelNewbies와 외부 기사들은 ext4/btrfs 성능 개선, FUSE io_uring 성능 개선, 블록 디바이스에 RWF_DONTCACHE 지원 같은 변화를 prominent하게 다룹니다.<sup id="fnref:4:1" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup> 속도 개선은 대체로 fast-path가 바뀌었다는 의미고, 운영에서 fast-path 변경은 리그레션이 숨어 있을 확률이 올라갑니다. 내 경험상 “성능이 좋아졌다”는 릴리스 노트가 뜨는 사이클은 오히려 GA 검증을 더 길게 잡습니다.</p>

<h3 id="smbksmbd-커널-내-파일-서버를-쓰는-조직은-커널-업그레이드가-곧-서비스-업그레이드">SMB/ksmbd: 커널 내 파일 서버를 쓰는 조직은 커널 업그레이드가 곧 서비스 업그레이드</h3>

<p>7.3에서 ksmbd가 Apple Time Machine 관련 확장을 지원한다는 언급이 반복됩니다.<sup id="fnref:4:2" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup> ksmbd를 실제 운영에 쓰는 곳은 커널 업그레이드가 곧 SMB 서버 업그레이드입니다. 이런 경우 GA 검증은 커널 smoke test로 끝나지 않고, SMB workload replay(특히 metadata-heavy, small file, durable handle 같은 케이스)까지 들어가야 합니다.</p>

<h3 id="bpf-관측정책이-커널에-더-붙어-있는-조직일수록-업그레이드-리스크가-커진다">BPF: 관측/정책이 커널에 더 붙어 있는 조직일수록 업그레이드 리스크가 커진다</h3>

<p>LWN은 BPF 프로그램을 위한 global per-CPU data를 변화 포인트로 언급합니다.<sup id="fnref:3:2" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> 내 경우는 eBPF를 “관측 도구” 정도로만 쓰면 영향이 제한적이지만, tc/XDP, LSM, runtime policy 등으로 쓰는 조직은 커널 업그레이드 검증에 “BPF 프로그램 호환성”이 독립된 트랙이 됩니다.</p>

<p>정리하면 7.3의 변화는 ‘driver/스토리지만 보면 된다’가 아니라, driver/스토리지 장애가 스케줄러/FS/BPF 변화와 결합해 더 복잡한 형태로 나온다는 쪽에 가깝습니다.</p>

<h2 id="ga-전환-체크리스트-1-내가-배포하는-커널의-아티팩트를-고정한다">GA 전환 체크리스트 1: “내가 배포하는 커널”의 아티팩트를 고정한다</h2>

<p>GA 검증은 기능보다 먼저 아티팩트를 고정하는 일입니다. 여기서 삐끗하면 이후 체크리스트가 전부 의미가 없어집니다.</p>

<h3 id="1-upstream-소스서명-검증을-파이프라인-시작점에-넣는다">1) upstream 소스/서명 검증을 파이프라인 시작점에 넣는다</h3>

<p>kernel.org는 릴리스 tarball과 detached PGP signature를 제공합니다.<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> 운영 파이프라인에서 최소한 다음은 자동화하는 편이 낫습니다.</p>

<ul>
  <li>tarball 다운로드</li>
  <li>signature 다운로드</li>
  <li>검증에 쓰는 공개키 체인 준비</li>
  <li>검증 결과를 빌드 메타데이터에 저장</li>
</ul>

<p>커널 릴리스 서명 검증 절차는 kernel.org 문서에 예시가 있습니다.<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup></p>

<h3 id="2-config를-테스트-대상으로-취급한다">2) config를 “테스트 대상”으로 취급한다</h3>

<p>커널 업그레이드 사고의 절반은 사실 config 사고입니다.</p>

<ul>
  <li>distro kernel을 그대로 쓰는 조직: distro의 config가 바뀌는 것도 업그레이드 리스크</li>
  <li>self-build 하는 조직: Kconfig 변경으로 동일한 .config가 다른 의미를 갖는 경우가 발생</li>
</ul>

<p>따라서 GA 체크리스트는 커널 버전뿐 아니라, 아래를 고정해야 합니다.</p>

<ul>
  <li>.config(원본 파일)</li>
  <li>toolchain 버전(gcc/clang/binutils)</li>
  <li>packaging 방식(커스텀 deb/rpm, initramfs 생성 방식)</li>
  <li>kernel cmdline</li>
</ul>

<p>이 고정이 되어야 캔리에서 터졌을 때 “커널 탓”인지 “패키징/부팅 경로 탓”인지 분리가 됩니다.</p>

<h3 id="3-out-of-tree-모듈-리스트를-사전에-확정한다">3) out-of-tree 모듈 리스트를 사전에 확정한다</h3>

<p>운영에서 커널을 올릴 때 가장 자주 깨지는 건 NVIDIA/OFED 같은 유명한 것만이 아닙니다. 스토리지 HBA, NIC, 보안 에이전트, 스토리지 필터, EDR, 커스텀 filesystem 등 out-of-tree 모듈이 하나라도 있으면 커널은 사실상 “플랫폼 ABI”가 됩니다.</p>

<p>GA 전환 체크리스트에는 최소한 이 목록이 들어가야 합니다.</p>

<ul>
  <li>out-of-tree 모듈 식별(패키지/경로/DKMS 여부)</li>
  <li>모듈 서명/secure boot 정책</li>
  <li>모듈 로드 실패 시 부팅 영향도(early boot에 필요한가)</li>
  <li>모듈이 접근하는 storage path 여부(blk/FS에 붙는지)</li>
</ul>

<h2 id="ga-전환-체크리스트-2-캔리-링-설계를-driver스토리지-중심으로-바꾼다">GA 전환 체크리스트 2: 캔리 링 설계를 driver·스토리지 중심으로 바꾼다</h2>

<p>RC 테스트는 “넓게” 커버하는 게 장점이지만, GA 캔리는 “좁고 깊게”가 장점입니다. 특히 driver/스토리지 리그레션은 특정 조합에서만 나오기 때문에, 링 설계가 하드웨어 매트릭스를 대표하지 못하면 아무 의미가 없습니다.</p>

<h3 id="링-설계예시-기능-검증-링과-장애-반경-제어-링을-분리한다">링 설계(예시): 기능 검증 링과 장애 반경 제어 링을 분리한다</h3>

<p>내가 커널 롤아웃에서 자주 쓰는 방식은, 링을 두 축으로 나눕니다.</p>

<ul>
  <li>기능 링: 새 기능을 실제로 쓰는 워크로드(예: ksmbd를 쓰는 링, FUSE를 쓰는 링, 특정 BPF 프로그램을 쓰는 링)</li>
  <li>장애 반경 링: 같은 기능을 쓰더라도 blast radius가 작은 노드/테넌트부터 배포</li>
</ul>

<p>driver/스토리지는 보통 “장애 반경 링”이 더 중요합니다. 이유는 간단합니다. 스토리지/driver 사고는 대부분 hard failure로 오기 때문에, 기능을 ‘조금만’ 쓰는 노드에서도 치명적입니다.</p>

<h3 id="하드웨어-매트릭스-스토리지-경로를-기준으로-분류한다">하드웨어 매트릭스: 스토리지 경로를 기준으로 분류한다</h3>

<p>서버 fleet에서 대표성을 잡는 기준을 CPU 세대나 메모리 크기로 잡으면 스토리지 리그레션이 잘 안 잡힙니다. 나는 다음 축을 우선합니다.</p>

<ul>
  <li>부트 디바이스 경로(NVMe/SATA/RAID/HBA)</li>
  <li>데이터 디바이스 경로(로컬 NVMe, JBOD+HBA, NVMe-oF, iSCSI)</li>
  <li>파일시스템(ext4/xfs/btrfs)</li>
  <li>암호화 계층(dm-crypt/LUKS)</li>
  <li>RAID 계층(mdraid, dm-raid, hw raid)</li>
  <li>multipath 여부</li>
</ul>

<p>이 축이 커버되면, 7.3에서 파일시스템/블록 계층의 변화가 실제 운영에서 어떤 형태로 나타나는지 더 빨리 볼 수 있습니다.<sup id="fnref:4:3" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></p>

<h3 id="롤백-패키지-롤백이-아니라-부팅-롤백이-되어야-한다">롤백: “패키지 롤백”이 아니라 “부팅 롤백”이 되어야 한다</h3>

<p>커널은 패키지만 되돌린다고 해결되지 않습니다. 다음 상태를 항상 보장해야 합니다.</p>

<ul>
  <li>이전 커널로 부팅 가능한 부트 엔트리 존재</li>
  <li>자동 롤백이 실패해도 원격 콘솔/시리얼/Out-of-band로 복구 가능</li>
  <li>initramfs까지 포함한 원자적 전환(부분 업데이트 방지)</li>
</ul>

<p>이건 RC 테스트에서는 거의 신경 쓰지 않는 영역이지만 GA에서는 가장 중요합니다.</p>

<h2 id="ga-전환-체크리스트-3-부팅서명initramfs-경로를-실제-배포처럼-검증한다">GA 전환 체크리스트 3: 부팅/서명/initramfs 경로를 실제 배포처럼 검증한다</h2>

<p>kernel.org는 tarball/patch/서명을 제공하고, signature를 검증하는 문서도 제공합니다.<sup id="fnref:1:2" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> 하지만 운영에서 더 흔한 장애는 “커널이 문제”가 아니라 “커널을 로딩하는 과정”이 문제인 경우입니다.</p>

<h3 id="secure-boot--모듈-서명-정책을-캔리-1단계에서-확정한다">secure boot + 모듈 서명 정책을 캔리 1단계에서 확정한다</h3>

<p>secure boot를 켠 환경에서는 다음이 반드시 체크리스트로 들어가야 합니다.</p>

<ul>
  <li>vmlinuz 서명 방식이 기존과 동일한가</li>
  <li>모듈 서명 키(조직 키/벤더 키)와 MOK enrollment 정책</li>
  <li>initramfs 안에 들어가는 모듈이 서명 정책을 위반하지 않는가</li>
</ul>

<p>이 경로는 RC에서 통과했어도 GA에서 자주 깨집니다. RC는 보통 개발자가 로컬에서 부팅해 보지만, 운영은 이미지 빌더/서명/배포/부트로더까지 전부 파이프라인으로 묶여 있기 때문입니다.</p>

<h3 id="initramfs-구성-변경을-스토리지-리그레션으로-오인하지-않는다">initramfs 구성 변경을 “스토리지 리그레션”으로 오인하지 않는다</h3>

<p>스토리지 장애처럼 보이는 부팅 실패의 많은 비율이 initramfs에 필요한 모듈이 누락된 케이스입니다.</p>

<ul>
  <li>NVMe 드라이버가 initramfs에 없어서 rootfs를 못 찾음</li>
  <li>dm-crypt 모듈/키스크립트 누락으로 LUKS unlock 실패</li>
  <li>RAID 어셈블 스크립트 변경으로 mdraid가 assemble되지 않음</li>
</ul>

<p>GA 체크리스트에서 중요한 건 “부팅 실패의 원인이 커널 코드인지”를 분리하는 진단 루틴입니다. 이 분리가 안 되면 캔리 링에서 실패했을 때 롤백은 빨라지지만, 원인 분석은 느려집니다.</p>

<h2 id="ga-전환-체크리스트-4-드라이버스토리지-검증을-smoke-test에서-증상-기반으로-확장한다">GA 전환 체크리스트 4: 드라이버·스토리지 검증을 smoke test에서 ‘증상 기반’으로 확장한다</h2>

<p>드라이버/스토리지 검증을 “fio 한 번 돌려서 OK”로 끝내면, 실제 사고 형태(리셋/타임아웃/큐 정지/간헐 오류)를 놓치기 쉽습니다. 특히 스토리지 경로 리그레션은 다음처럼 나옵니다.</p>

<ul>
  <li>평상시 정상 → GC/trim/재시도 조건에서 급격히 느려짐</li>
  <li>고부하에서 queue timeout → 장치 리셋 → filesystem remount ro</li>
  <li>특정 전원관리 상태에서 링크 재훈련 → 디바이스 재인식</li>
</ul>

<h3 id="실행-가능한-예시-부팅-직후-커널-스모크--io-증상-체크-스크립트">(실행 가능한 예시) 부팅 직후 커널 스모크 + I/O 증상 체크 스크립트</h3>

<p>아래 스크립트는 “캔리 노드가 새 커널로 부팅한 직후”에 돌려서, 부팅 경로와 스토리지 경로에서 흔히 보이는 증상들을 빠르게 잡는 용도입니다. 파괴적 테스트를 피하기 위해 raw device에는 쓰지 않고, 지정한 디렉터리에 파일을 만들어 fio를 돌립니다.</p>

<ul>
  <li>대상: Ubuntu/Debian 계열 기준(다른 배포판도 유사)</li>
  <li>의존성: fio, jq</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">TEST_DIR</span><span class="o">=</span><span class="k">${</span><span class="nv">TEST_DIR</span><span class="k">:-</span><span class="p">/var/lib/kernel-ga-smoke</span><span class="k">}</span>
<span class="nv">TEST_SIZE</span><span class="o">=</span><span class="k">${</span><span class="nv">TEST_SIZE</span><span class="k">:-</span><span class="nv">2G</span><span class="k">}</span>
<span class="nv">RUNTIME</span><span class="o">=</span><span class="k">${</span><span class="nv">RUNTIME</span><span class="k">:-</span><span class="nv">30</span><span class="k">}</span>

<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">"</span>

log<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">'[%s] %s\n'</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-Is</span><span class="si">)</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$*</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>

log <span class="s2">"uname: </span><span class="si">$(</span><span class="nb">uname</span> <span class="nt">-a</span><span class="si">)</span><span class="s2">"</span>

<span class="c"># 1) 커널 taint 체크 (0이 이상적)</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-r</span> /proc/sys/kernel/tainted <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">TAINT</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> /proc/sys/kernel/tainted<span class="si">)</span>
  log <span class="s2">"kernel.tainted=</span><span class="nv">$TAINT</span><span class="s2">"</span>
<span class="k">fi</span>

<span class="c"># 2) dmesg에서 스토리지/드라이버 계열 증상 키워드 스캔</span>
<span class="c"># 운영에서는 키워드 기반만으로 결론 내리면 안 되지만, 캔리 게이트로는 유용합니다.</span>
<span class="nv">DMESG_OUT</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">/dmesg.txt"</span>
dmesg <span class="nt">-T</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$DMESG_OUT</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">true

</span><span class="nv">KEYWORDS</span><span class="o">=</span><span class="s1">'I/O error|blk_update_request|Buffer I/O error|EXT4-fs error|XFS .* ERROR|BTRFS error|nvme .* timeout|nvme .* reset|AER:|ata.*failed|SAS:.*reset|scsi.*aborted command|hung task|soft lockup|hard lockup|rcu_sched detected stalls'</span>
<span class="nv">HITS</span><span class="o">=</span><span class="si">$(</span><span class="nb">grep</span> <span class="nt">-E</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$KEYWORDS</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$DMESG_OUT</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">true</span><span class="si">)</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$HITS</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span>log <span class="s2">"dmesg keyword hits found:"</span>
  <span class="nb">printf</span> <span class="s1">'%s\n'</span> <span class="s2">"</span><span class="nv">$HITS</span><span class="s2">"</span> | <span class="nb">tail</span> <span class="nt">-n</span> 200
  <span class="c"># 캔리에서는 여기서 실패로 처리하는 게 대체로 맞습니다.</span>
  <span class="nb">exit </span>2
<span class="k">fi</span>

<span class="c"># 3) 블록 디바이스/마운트 요약(관측용)</span>
lsblk <span class="nt">-J</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">/lsblk.json"</span>
jq <span class="nt">-r</span> <span class="s1">'.blockdevices[] | [.name,.type,(.size//""),(.mountpoint//"-")] | @tsv'</span> <span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">/lsblk.json"</span> <span class="se">\</span>
  | <span class="nb">sed</span> <span class="s1">$'s/</span><span class="se">\t</span><span class="s1">/</span><span class="se">\t</span><span class="s1">|</span><span class="se">\t</span><span class="s1">/g'</span> <span class="se">\</span>
  | <span class="k">while </span><span class="nb">read</span> <span class="nt">-r</span> line<span class="p">;</span> <span class="k">do </span>log <span class="s2">"lsblk | </span><span class="nv">$line</span><span class="s2">"</span><span class="p">;</span> <span class="k">done</span>

<span class="c"># 4) fio: 파일 기반 랜덤 read/write. 데이터 디렉터리만 확인하면 파일시스템/스토리지 경로에서</span>
<span class="c">#    기본적인 timeout/에러는 잡힙니다.</span>
<span class="k">if</span> <span class="o">!</span> <span class="nb">command</span> <span class="nt">-v</span> fio <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="k">then
  </span>log <span class="s2">"fio not found. Install: apt-get install -y fio"</span>
  <span class="nb">exit </span>3
<span class="k">fi

</span><span class="nv">FIO_JOB</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">/fio.job"</span>
<span class="nb">cat</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$FIO_JOB</span><span class="s2">"</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh">
[global]
ioengine=io_uring
thread=1
direct=1
time_based=1
runtime=</span><span class="k">${</span><span class="nv">RUNTIME</span><span class="k">}</span><span class="sh">
size=</span><span class="k">${</span><span class="nv">TEST_SIZE</span><span class="k">}</span><span class="sh">
filename=</span><span class="k">${</span><span class="nv">TEST_DIR</span><span class="k">}</span><span class="sh">/fio.testfile
group_reporting=1

[randrw]
rw=randrw
bs=4k
iodepth=32
</span><span class="no">EOF

</span>log <span class="s2">"running fio (</span><span class="k">${</span><span class="nv">RUNTIME</span><span class="k">}</span><span class="s2">s, </span><span class="k">${</span><span class="nv">TEST_SIZE</span><span class="k">}</span><span class="s2">) in </span><span class="k">${</span><span class="nv">TEST_DIR</span><span class="k">}</span><span class="s2">"</span>
fio <span class="s2">"</span><span class="nv">$FIO_JOB</span><span class="s2">"</span> | <span class="nb">tee</span> <span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">/fio.out"</span>

<span class="c"># 5) fio 이후에도 dmesg에 새로 찍힌 에러가 없는지 재확인</span>
<span class="nv">DMESG_OUT2</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TEST_DIR</span><span class="s2">/dmesg.after.txt"</span>
dmesg <span class="nt">-T</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$DMESG_OUT2</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">true
</span><span class="nv">HITS2</span><span class="o">=</span><span class="si">$(</span><span class="nb">grep</span> <span class="nt">-E</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$KEYWORDS</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$DMESG_OUT2</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">true</span><span class="si">)</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$HITS2</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span>log <span class="s2">"post-fio dmesg keyword hits found:"</span>
  <span class="nb">printf</span> <span class="s1">'%s\n'</span> <span class="s2">"</span><span class="nv">$HITS2</span><span class="s2">"</span> | <span class="nb">tail</span> <span class="nt">-n</span> 200
  <span class="nb">exit </span>4
<span class="k">fi

</span>log <span class="s2">"GA smoke test: PASS"</span>
</code></pre></div></div>

<p>실행 예시:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> fio jq
<span class="nb">sudo </span><span class="nv">TEST_DIR</span><span class="o">=</span>/var/lib/kernel-ga-smoke <span class="nv">RUNTIME</span><span class="o">=</span>20 <span class="nv">TEST_SIZE</span><span class="o">=</span>1G bash ./kernel-ga-smoke.sh
</code></pre></div></div>

<p>이 스크립트는 완전한 검증이 아닙니다. 다만 캔리의 1차 게이트로는 효과가 있습니다. 내가 중요하게 보는 건 fio 결과 숫자 자체가 아니라, fio가 “문제를 일으키는 상황을 만들었을 때” dmesg에 timeout/reset/error가 올라오느냐입니다.</p>

<h3 id="스토리지-검증에서-자주-빠지는-포인트">스토리지 검증에서 자주 빠지는 포인트</h3>

<ul>
  <li>TRIM/discard 경로: 랜덤 write만으로는 안 드러나는 NVMe FW 이슈가 있습니다.</li>
  <li>작은 파일/metadata 워크로드: DB WAL처럼 순차 write만으로도 문제가 나올 수 있고, 반대로 metadata-heavy가 터지는 경우도 많습니다.</li>
  <li>suspend/resume 또는 PCIe 링크 전원관리: 데이터센터에서도 특정 플랫폼에서 반복됩니다.</li>
</ul>

<p>여기까지는 “체크리스트”의 영역이고, 이 다음부터는 “내 서비스 워크로드 재현”이 들어가야 합니다.</p>

<h2 id="반론과-회의론-mainline73을-ga로-다루는-게-맞나">반론과 회의론: mainline(7.3)을 GA로 다루는 게 맞나</h2>

<p>이 지점에서 현실적인 반론이 두 가지 나옵니다.</p>

<h3 id="1-mainline은-운영-커널이-아니다">1) “mainline은 운영 커널이 아니다”</h3>

<p>맞는 말입니다. kernel.org 문서에서도 -rc는 enthusiasts/개발자 대상이고, mainline이 릴리스된 뒤 stable 업데이트가 이어진다고 설명하지만<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>, 여기서 stable은 “운영 적합성 보증”이 아닙니다.</p>

<p>그럼에도 7.3을 GA 관점으로 봐야 하는 조직이 있습니다.</p>

<ul>
  <li>자체 배포판/어플라이언스/커널을 직접 빌드해 배포하는 조직</li>
  <li>특정 하드웨어 enablement를 기다리는 조직</li>
  <li>커널 기능(예: sched_ext, ksmbd)을 제품 가치로 직접 쓰는 조직<sup id="fnref:4:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<p>이 경우 mainline 릴리스 시점이 곧 “내가 GA를 준비해야 하는 시점”이 됩니다.</p>

<h3 id="2-어차피-distro-커널은-backport라-73을-바로-못-쓴다">2) “어차피 distro 커널은 backport라 7.3을 바로 못 쓴다”</h3>

<p>일반적인 엔터프라이즈 리눅스 운영에서는 더 현실적인 반론입니다. 다만 이 말은 “7.3을 배포하지 않는다”일 뿐 “7.3에서 들어온 변화가 내 운영에 영향을 주지 않는다”가 아닙니다. backport는 시간차를 두고 들어오고, 특히 파일시스템/드라이버 계열은 선별적으로 들어오면서 예상치 못한 조합을 만듭니다.</p>

<p>그래서 나는 커널 mainline 릴리스 때 GA 체크리스트를 정리해 두고, 실제 배포는 distro 커널/HWE/벤더 커널이 그 변화 집합을 들고 들어오는 시점에 맞춰 실행합니다. 이 관점은 예전에 썼던 <a href="https://daewooki.github.io/posts/linux-stable-kernel-rollout-checklist-724/">Linux stable 커널 롤아웃 체크리스트: 7.2.4</a>의 연장선이기도 합니다.</p>

<h2 id="앞으로-지켜볼-것-73-이후의-안정화-흐름을-운영-신호로-읽는다">앞으로 지켜볼 것: 7.3 이후의 안정화 흐름을 운영 신호로 읽는다</h2>

<p>7.3 릴리스 자체보다 중요한 건 “7.3 이후 무엇이 7.3.y로 backport되고, 무엇이 회귀로 남는가”입니다.</p>

<ul>
  <li>regzbot과 KernelCI의 결합은 회귀 추적을 더 체계화하는 흐름으로 보입니다.<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup> upstream 회귀가 어떤 속도로 닫히는지, 어느 서브시스템에서 오래 걸리는지 자체가 운영의 리스크 모델 입력이 됩니다.</li>
  <li>7.3의 prominent changes(sched_ext, group-scheduling, filesystems, SMB, virtualization)가 실제로 안정화 과정에서 어떤 유형의 fix를 많이 필요로 하는지 관찰해야 합니다.<sup id="fnref:3:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
  <li>kernel.org가 정의한 stable 규칙(메인라인에 먼저 들어가야 stable에 backport 가능, stable@vger.kernel.org 프로세스 등)은 “내가 고칠 수 있는 문제”와 “기다려야 하는 문제”를 가르는 기준이 됩니다.<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup></li>
</ul>

<p>운영 체크리스트 관점에서는 ‘버그가 있냐 없냐’가 아니라 ‘버그가 있을 때 내가 감당 가능한 범위에서 드러나고, 자동 롤백이 되느냐’가 더 중요합니다.</p>

<h2 id="지금-시점2026-10-24-kst에-rcga-전환으로-해야-하는-결론">지금 시점(2026-10-24 KST)에 RC→GA 전환으로 해야 하는 결론</h2>

<p>Linux 7.3이 2026-10-18에 mainline로 확정되었고<sup id="fnref:1:3" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>, prominent change가 스케줄러/FS/가상화/SMB/BPF까지 넓게 퍼져 있습니다.<sup id="fnref:3:4" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> 이 상황에서 지난주까지의 RC 회귀 테스트를 그대로 연장하는 건 방향이 다릅니다.</p>

<p>내 결론은 간단합니다.</p>

<ul>
  <li>RC에서 GA로 넘어가는 순간, 테스트의 주체는 upstream이 아니라 내 조직이고, 대상은 커널 코드가 아니라 배포 시스템이다.</li>
  <li>driver/스토리지 중심 환경에서는 “캔리 설계 + 부팅 롤백 + 증상 기반 관측”이 체크리스트의 앞쪽에 와야 한다.</li>
  <li>7.3 같은 대형 사이클에서는 성능 회귀를 기능 결함과 같은 급으로 취급해야 하고, 그 판단을 자동화된 게이트로 내리는 편이 안전하다.</li>
</ul>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://www.kernel.org/">kernel.org 메인 페이지(7.3 릴리스/날짜/아티팩트)</a></li>
  <li><a href="https://www.kernel.org/releases.html">kernel.org: Active kernel releases(Prepatch/Mainline/Stable/Longterm 정의)</a></li>
  <li><a href="https://lwn.net/Articles/1101143/">LWN: The 7.3 kernel has been released</a></li>
  <li><a href="https://lwn.net/Articles/1101170/">LWN: Linux 7.3(LKML 릴리스 메일 아카이브)</a></li>
  <li><a href="https://kernelnewbies.org/Linux_7.3">KernelNewbies: Linux 7.3 changelog</a></li>
  <li><a href="https://www.gigazine.net/gsc_news/en/20261020-linux-kernel-7-3/">GIGAZINE: Linux 7.3 has been released</a></li>
  <li><a href="https://www.kernel.org/doc/html/latest/process/handling-regressions.html">kernel.org 문서: Handling regressions</a></li>
  <li><a href="https://www.kernel.org/doc/html/latest/admin-guide/reporting-regressions.html">kernel.org 문서: Reporting regressions</a></li>
  <li><a href="https://kernelci.org/blog/2026/05/04/regzbot-joins-kernelci-strengthening-linux-kernel-regression-tracking/">KernelCI 블로그: Regzbot joins KernelCI</a></li>
  <li><a href="https://www.kernel.org/doc/html/latest/process/stable-kernel-rules.html">kernel.org 문서: Stable kernel rules</a></li>
  <li><a href="https://daewooki.github.io/posts/linux-kernel-rc-regression-pipeline/">Linux 7.3 RC 회귀 테스트·드라이버 검증 자동화 설계</a></li>
  <li><a href="https://daewooki.github.io/posts/linux-stable-kernel-rollout-checklist-724/">Linux stable 커널 롤아웃 체크리스트: 7.2.4</a></li>
  <li><a href="https://daewooki.github.io/posts/linux-kernel-stable-rollout-contract/">Linux 7.2.6 stable 릴리스: 작은 패치가 운영에선 큰 리스크가 되는 이유</a></li>
  <li><a href="https://daewooki.github.io/posts/systemd-stable-backport-vs-upgrade-policy/">systemd 안정 릴리스와 운영 기준: 백포트 vs 자체 업그레이드</a></li>
  <li><a href="https://daewooki.github.io/posts/postgresql-19-rc-window-redesign/">PostgreSQL 19 RC 기간 확장에 맞춘 테스트 윈도우 재설계</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://www.kernel.org/">https://www.kernel.org/</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:1:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:1:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://www.kernel.org/releases.html">https://www.kernel.org/releases.html</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://lwn.net/Articles/1101143/">https://lwn.net/Articles/1101143/</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:3:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:3:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:3:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://kernelnewbies.org/Linux_7.3">https://kernelnewbies.org/Linux_7.3</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:4:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:4:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:4:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:4:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://www.kernel.org/doc/html/next/process/2.Process.html">https://www.kernel.org/doc/html/next/process/2.Process.html</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://kernel.org/doc/html/next/process/handling-regressions.html">https://kernel.org/doc/html/next/process/handling-regressions.html</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://kernel.org/signature.html">https://kernel.org/signature.html</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://kernelci.org/blog/2026/05/04/regzbot-joins-kernelci-strengthening-linux-kernel-regression-tracking/">https://kernelci.org/blog/2026/05/04/regzbot-joins-kernelci-strengthening-linux-kernel-regression-tracking/</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p><a href="https://cdn.kernel.org/doc/html/latest/process/stable-kernel-rules.html">https://cdn.kernel.org/doc/html/latest/process/stable-kernel-rules.html</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>joyuno</name></author><category term="News" /><category term="Systems" /><category term="linux-kernel" /><category term="release-engineering" /><category term="canary-rollout" /><category term="drivers" /><category term="storage" /><category term="regression-testing" /><summary type="html"><![CDATA[Linux 7.3 메인라인 확정 이후 RC 회귀 테스트를 GA 캔리 롤아웃 검증으로 전환하는 운영 체크리스트를 정리합니다.]]></summary></entry></feed>