인프라

Declarative Firewall Sync가 규칙을 정리하고 액세스를 차단할 때

방화벽 동기화가 수동으로만 존재하던 규칙을 정리하여 라이브 트래픽을 차단했습니다. 여기에 사후 분석, 근본 원인, 그리고 정리(prune) 작업을 안전하게 만드는 방법이 있습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

코드에 있는 신뢰 출처(source of truth)에 맞춰 방화벽 규칙을 조정하는 선언적 동기화 루프는 지시받은 대로 정확히 수행했고, 그것이 문제였습니다. 어느 날 오후, 이 루프는 실제 운영 중인 규칙 세트를 제거하여 몇 달 동안 잘 작동하던 트래픽을 차단했습니다. 아무도 코드를 변경하지 않았습니다. 그것이 삭제한 규칙들은 선언적 소스 외부의 콘솔에서 수동으로 추가된 것이었고, 동기화 과정은 소스에 없는 모든 것을 정리(prune)해야 할 쓰레기로 취급했습니다. 이것은 해당 사고에 대한 사후 분석(postmortem)으로, 의도적으로 일반화하여 작성되었으며, 자동 정리(prune)가 무기가 되는 것을 방지하는 설계 변경 사항에 대한 내용입니다.

무슨 일이 있었나

환경에는 리컨사일러(reconciler)가 있었습니다. 버전 관리 시스템에 있는 선언적 소스는 각 네트워크 경계에 대해 원하는 방화벽 및 보안 그룹 규칙을 기술했습니다. 예약된 일정에 따라 자동화 시스템이 해당 소스를 읽고, 인프라의 실제 규칙 집합과 비교하여 실제 집합을 소스와 일치시켰습니다. 소스에 있는 새로운 규칙은 생성되었습니다. 실제 환경에는 존재하지만 소스에는 없는 규칙은 삭제되었습니다. 이렇게 일치시키기 위해 삭제하는 동작은 선언적 조정의 핵심이며, 바로 이 점이 장애의 전체 원인이었습니다.

몇 주 전, 누군가 긴급한 문제를 해결하기 위해 인프라 콘솔에서 직접 몇 가지 규칙을 추가했습니다. 서비스가 의존하는 특정 아웃바운드 경로와 두어 개의 포트였습니다. 이 규칙들은 선언적 소스에 다시 기록되지 않았습니다. 이 규칙들은 오직 실제 인프라에만 존재했습니다. 수동 편집 이후 해당 경계에 대해 동기화가 실행되지 않았거나, 제거(prune)하지 않는 모드로 실행되었기 때문에 한동안은 아무 문제가 없었습니다.

그러다 일반적인 동기화가 실행되었습니다. 동기화 시스템은 실제 환경 측에는 있지만 선언적 소스에는 없는 규칙들을 발견했습니다. 규약에 따라, 이 규칙들은 수정되어야 할 드리프트(drift)로 간주되어 삭제되었습니다. 아웃바운드 경로는 사라졌습니다. 제거된 포트 중 하나를 통과하던 상태 확인(health check)이 실패하기 시작했습니다. 수동으로 추가된 규칙에 의존하던 트래픽이 멈췄습니다. 자동화 시스템은 성공을 보고했습니다. 자동화 시스템의 관점에서는 이제 실제 상태가 소스와 완벽하게 일치했기 때문입니다. 중요한 경고는 동기화 시스템에서 오지 않았습니다. 그 경고는 더 이상 필요한 곳에 도달할 수 없게 된 서비스들로부터 왔습니다.

간략한 타임라인

이 순서는 짧고 여러 팀에 걸쳐 반복되므로, 명확하게 기술할 가치가 있습니다.

  1. 규칙 세트가 선언적으로 정의되고 자동으로 조정됩니다. 여기까지는 올바릅니다.
  2. 긴급한 요구사항이 발생합니다. 누군가 문제를 즉시 해결하기 위해 콘솔에서 수동으로 규칙을 추가합니다. 선언적 소스는 업데이트되지 않고, 따라서 두 번째 진실의 원천(source of truth)이 조용히 생겨납니다.
  3. 시간이 흐릅니다. 수동으로 추가한 규칙은 계속 작동합니다. 아무도 지켜보지 않는 사이에 실시간 상태와 선언적 소스 간의 격차가 벌어집니다.
  4. prune이 활성화된 상태로 정기 동기화가 실행됩니다. 동기화 작업은 수동 규칙을 관리되지 않는 드리프트(drift)로 간주하고 삭제합니다.
  5. 접근이 차단됩니다. 장애는 그것을 유발한 도구가 아닌 다운스트림에서 드러나므로, 사고 발생 초기 몇 분은 엉뚱한 곳을 찾는 데 허비됩니다.

원인이 명확해지자 복구 자체는 빨랐습니다. 사라진 규칙들은 이번에는 선언적 소스에 다시 추가되었고, 접근이 복구되었습니다. 불편했던 부분은 해결 과정이 아니었습니다. 자동화가 몇 주 동안이나 예약된 실행 한 번으로 이런 결과를 낳을 수 있는 상태였다는 점, 그리고 그 설계가 그 결과를 운이 나빴던 것이 아니라 필연적인 것으로 만들었다는 점을 깨달은 것이었습니다.

근본 원인: 하나의 규칙 집합에 대한 두 개의 진실 공급원

가장 큰 원인은 동기화 버그가 아니었습니다. 동기화는 설계된 대로 작동했습니다. 원인은 두 곳에서 모두 현실을 정의할 수 있었고, 그중 하나만 살아남도록 허용되었다는 점입니다.

선언적 소스는 단일 진실 공급원(single source of truth)이라고 주장했습니다. 콘솔에서는 사람이 직접 규칙을 작성할 수 있었고, 이로 인해 콘솔은 사실상 두 번째 진실 공급원이 되었습니다. 두 가지가 모두 사용되는 한, 시스템에는 서로 다른 의견을 가진 두 개의 권위가 존재했으며, 조정자(reconciler)는 소스에 포함되지 않은 모든 것을 삭제함으로써 소스에 유리하게 모든 불일치를 해결하도록 구축되었습니다. 수동 규칙은 시스템이 보존할 추가 사항이 아니었습니다. 그것은 시스템이 지우도록 설계된 차이점이었습니다.

세 가지 구체적인 결정이 그 구조적 결함을 장애로 바꾸었습니다.

첫째, 수동 편집 경로가 열려 있는 상태에서, 자동화는 정리(pruning)를 통해 소스를 유일한 진실로 강제했습니다. 양쪽 모두를 가질 수는 없습니다. 소스가 권위 있고 정리 기능이 켜져 있다면, 수동 편집은 지름길이 아니라 언제 터질지 모르는 시한폭탄입니다.

둘째, 동기화는 diff 미리 보기나 승인 절차 없이 변경 사항을 즉시 적용했습니다. 동기화는 사람에게 삭제하려는 규칙 목록을 보여주지 않았습니다. "아웃바운드 경로 하나를 포함하여 3개의 규칙을 제거하려고 합니다"라고 출력하는 가상 실행(dry run)이 있었다면, 한 줄을 읽는 시간 안에 사고를 막을 수 있었을 것입니다.

셋째, 그리고 가장 중요한 점은, 설계가 제거와 추가를 동일한 종류의 변경으로 취급했다는 것입니다. 소스에는 있지만 인프라에는 없는 규칙을 추가하는 것은 위험이 낮습니다. 최악의 경우는 이미 차단된 트래픽이 잠시 더 차단되는 것뿐입니다. 규칙을 제거하는 것은 위험한 방향입니다. 왜냐하면 활발하게 사용 중인 접근을 차단할 수 있고, 그 영향 범위는 사후에나 알 수 있기 때문입니다. 두 작업에 동일한 자동적이고 보호되지 않는 처리를 부여한 것은 안전한 작업과 파괴적인 작업이 동일한 신뢰 수준으로 실행되었음을 의미합니다.

근본 원인과 대책

수정 사항은 원인과 일대일로 대응하며, 각 수정 사항은 독립적으로 장애 범위를 좁히므로, 하나의 수정 사항에 틈이 생겨도 장애가 다시 발생하지 않습니다.

근본 원인 허용한 문제 대책
두 개의 정보 소스(코드 및 콘솔) 동기화가 드리프트로 간주한 수동 규칙 단일 정보 소스: 수동 콘솔 편집을 비활성화하고, 모든 변경 사항은 선언적 소스를 통해 이루어집니다. 드리프트는 조용히 생성되지 않고 감지되어 알림이 전송됩니다.
미리보기나 승인 없이 적용된 Prune 실시간 규칙의 자동 삭제 Prune은 항상 dry-run diff를 생성하며, 무언가를 삭제하기 전에 명시적인 사람의 승인을 요구합니다.
제거를 추가처럼 처리함 파괴적인 작업이 안전한 작업이라는 신뢰 하에 실행됨 비대칭 처리: 추가는 자동으로 적용되고, 제거는 검토 후에만 적용됩니다.
관리 액세스에 대한 보호 부재 동기화가 절대 건드려서는 안 되는 규칙을 삭제할 수 있었음 보호 목록이 관리 및 상태 확인 규칙을 조정(reconciliation)에서 완전히 제외합니다.
롤백 지점 없이 변경 사항이 부분적으로 적용됨 부분적인 상태 및 느린 복구 원스텝 롤백을 위한 변경 전 스냅샷을 사용한 원자적 적용
대량 삭제에 대한 신호 없음 장애가 몇 분 늦게 다운스트림에서 표면화됨 동기화가 작은 임계값보다 많은 규칙을 제거하려고 할 때 경고가 발생합니다.

예방: prune을 보호된 작업으로 만들기

핵심 원칙은 선언적 시스템의 안전성이 삭제를 처리하는 방식에 따라 결정된다는 것입니다. 생성은 관대합니다. 삭제는 그렇지 않습니다. 재설계를 통해 안전한 대다수의 변경 사항은 자동으로 유지하면서, 제거는 관문을 통과해야 하는 작업으로 만들었습니다.

prune은 이제 두 단계로 실행됩니다. 첫 번째 단계에서는 소스와 라이브 상태 간의 차이를 계산하고 방향별로 그룹화하여 출력합니다. 한쪽에는 추가 사항, 다른 한쪽에는 제거 사항이 표시됩니다. 추가는 기존 액세스를 차단할 수 없으므로 자체적으로 추가 사항을 적용합니다. 단 하나의 제거 사항도 적용하지 않습니다. 대신 실행을 중지하고 각 항목을 판단하기에 충분한 컨텍스트와 함께 제거할 항목을 승인받기 위해 제시합니다. 사람이 목록을 읽고 승인하거나 승인하지 않습니다. 명시적인 승인이 있어야만 제거 단계가 실행됩니다.

이러한 비대칭성이 해결의 핵심입니다. 정상 상태(steady state)에서는 대부분의 동기화에 추가 사항만 포함되거나 변경 사항이 전혀 없으며, 이러한 동기화는 사람의 개입 없이 그대로 실행됩니다. 무언가를 삭제하려는 드문 동기화는 사람의 개입이 필요한 드문 동기화입니다. 관문의 비용은 중단을 유발할 수 있는 작업에만 적용되며, 바로 그 지점이 마찰이 필요한 곳입니다.

임계값 경고가 이를 뒷받침합니다. 제안된 prune이 한 번에 소수의 규칙 이상을 제거하려는 경우, 이는 크지만 일상적인 변경이 아니라 입력에 문제가 있다는 신호로 처리됩니다. 갑자기 20개의 규칙을 삭제하려는 동기화는 20개의 규칙을 제거하려는 실제 의도를 반영하기보다는 손상되거나 비어 있는 소스를 읽고 있을 가능성이 훨씬 높습니다. 경고는 차이(diff)가 승인되기 전에 발생합니다.

예방: 단일 진실 공급원과 보호 목록

정리(prune)에 대한 게이트는 드리프트의 피해를 제한합니다. 드리프트를 제거하는 것이 나머지 절반입니다. 한 곳에서만 규칙을 정의할 수 있다면, 리컨실러는 유효한 라이브 규칙을 쓰레기로 보지 않습니다. 모든 유효한 규칙이 조정 대상인 소스에 존재하기 때문입니다.

이는 콘솔 경로를 차단하는 것을 의미했습니다. 사람이 콘솔을 통해 방화벽 및 보안 그룹 규칙을 직접 편집하는 것이 비활성화되었고, 그래서 선언적 소스는 의도뿐만 아니라 실제로도 단일 진실 공급원이 되었습니다. 긴급한 변경은 여전히 발생하지만, 동일한 파이프라인을 통해 흐르는 소스에 대한 작고 빠른 변경으로 이루어집니다. 이는 콘솔 편집보다 약간만 느릴 뿐이며, 두 권한 주체가 충돌하는 대신 합의된 상태로 남게 합니다. 별도의 드리프트 감지기가 스케줄에 따라 실행되며, 라이브 상태와 소스가 조금이라도 일치하지 않으면 경고를 보냅니다. 따라서 정리(prune)가 작동하기 훨씬 전에 격차가 경고로 포착됩니다.

보호 목록은 마지막 허점을 막아줍니다. 일부 규칙은 소스의 내용과 상관없이 절대 정리(prune)되어서는 안 됩니다. 운영자의 접근성을 유지하는 규칙이기 때문입니다. 관리 접근 및 헬스 체크가 통과하는 경로가 명백한 예입니다. 이러한 것들은 보호 대상으로 표시되고 조정에서 완전히 제외됩니다. 완전히 비어 있거나 손상된 소스조차도 이를 삭제할 수 없습니다. 이는 잘못된 소스로 인해 모든 사람이 문제를 해결하는 데 필요한 바로 그 인프라에서 차단되는 실패 모드가 불가능해진다는 것을 의미합니다. 보호 목록은 의도적으로 짧게 유지되며, 모든 항목은 "이 규칙이 사라지면, 우리가 여전히 피해를 복구하기 위해 접근할 수 있는가?"라는 한 가지 질문에 답함으로써 그 자격을 얻습니다.

이러한 보장을 담고 있는 리컨실러 설정의 형태는 다음과 같습니다. 정확한 스키마가 중요한 것이 아니라, 그 속성이 중요합니다.

reconcile:
  # Additions apply on their own. Removals never do.
  auto_apply:
    additions: true
    removals: false
  # Removals produce a diff and wait for a human.
  prune:
    require_approval: true
    dry_run_first: true
    # A prune larger than this is treated as a bad input, not a big change.
    alert_threshold: 5
  # Rules the reconciler is forbidden to delete, whatever the source says.
  protect:
    - name: management-access
      match: { port: 22, direction: inbound }
    - name: health-check
      match: { port: 8080, direction: inbound }
  # Snapshot before any change so a bad apply rolls back in one step.
  snapshot:
    before_apply: true
    retain: 10

예방: 원자성, 스냅샷, 관측 가능성

마지막 계층은 잘못된 변경 사항이 어쨌든 통과될 것이라고 가정하는데, 이는 결국 하나는 통과될 것이기 때문이며, 얼마나 빨리 되돌릴 수 있는지를 묻습니다. 어떠한 적용(apply) 전에도 리컨실러는 현재 라이브 규칙 세트의 스냅샷을 찍습니다. 적용(apply)으로 인해 깨진 상태가 발생하면, 복구는 압박감 속에서 메모리로부터 규칙을 재구성하는 것이 아니라 스냅샷을 복원하는 것입니다. 플랫폼이 허용하는 경우 변경 사항은 원자적으로 적용되므로, 실행 시 절반의 규칙만 업데이트되고 나머지 절반은 그렇지 않은 상태로 남지 않습니다. 이는 그 자체로 일종의 중단이며, 깔끔한 실패보다 원인을 파악하기 더 어렵습니다.

관측 가능성은 이 순환 고리를 완성합니다. 가장 유용한 단일 신호는 가장 단순한 신호인 것으로 밝혀졌습니다. 바로 경계(boundary)당 활성 규칙의 수이며, 이 수가 급격히 감소할 때 경고가 발생합니다. 한 번의 동기화(sync)에서 규칙 수가 40개에서 34개로 떨어지는 것은 즉시 질문을 던져볼 가치가 있는 문제이며, 이는 개별 규칙이 무엇을 하는지 이해하는 것에 의존하지 않습니다. 이것을 라이브 상태와 소스가 일치하지 않는 것을 감시하는 드리프트 감지기(drift detector)와 결합하면, 이 둘은 사람이 트래픽 실패를 알아차리기 훨씬 전에 느린 문제(드리프트 누적)와 빠른 문제(동기화 시 대량 삭제)를 모두 잡아냅니다.

일반적인 교훈: 선언적 방식이 안전한 것은 아닙니다

선언적 자동화에 대한 마음 편한 이야기는 그것이 인적 오류를 제거한다는 것입니다. 원하는 상태를 작성하고, 기계가 이를 강제하도록 하며, 수동 실수를 멈추는 것입니다. 그 이야기는 절반만 사실입니다. 선언적 조정(reconciliation)은 특정 종류의 드리프트(drift)를 제거하며, 추가 작업의 경우 수동 편집보다 확실히 더 안전합니다. 그것이 하지 못하는 것은 삭제를 안전하게 만드는 것이며, 조용히 둘 이상의 진실의 원천(source of truth)을 가질 때의 위험을 높입니다.

두 곳에서 현실을 정의할 수 있는 순간, 모든 충돌을 삭제로 해결하는 강제 장치(enforcer)는 안전 메커니즘이 아닙니다. 그것은 기계의 속도로 잘못된 진실에 따라 행동하는 자동화된 방법입니다. 여기서의 실패는 자동화가 사용되었다는 점이 아닙니다. 그것은 두 번째 비공식적인 진실의 원천이 존재하도록 허용된 시스템에서, 파괴적인 작업이 안전한 작업과 동일한 신뢰를 부여받았다는 점입니다.

여기서 나오는 규칙은 짧습니다. 정확히 하나의 진실의 원천을 유지하고, 드리프트가 형성되도록 두기보다는 그것으로부터의 드리프트를 감지하십시오. 자동화가 안전한 방향을 자유롭게 적용하도록 하십시오. 위험한 방향에는 인간의 개입(human gate)을 두십시오. 왜냐하면 삭제는 접근을 차단하는 작업이며, 아무리 선언적으로 깔끔하게 정리해도 그 사실은 변하지 않기 때문입니다. 정리(prune) 작업 앞에 사람이 없는 파이프라인은 신중한 운영자보다 더 신뢰할 수 없습니다. 그것은 신중한 운영자의 최악의 오후가 타이머에 맞춰 실행되도록 예약된 것과 같습니다.

관련 글