diff --git a/.gitignore b/.gitignore index a4e1e21..cf1876d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /.quarto/ -docs/* +/docs/* + +/tools/imports/**/*.zip **/*.quarto_ipynb diff --git a/README.md b/README.md index a7d985a..3d25ba6 100644 --- a/README.md +++ b/README.md @@ -80,4 +80,4 @@ title: "페이지 제목" ## 라이선스 -이 프로젝트는 MIT 라이선스 하에 배포됩니다. +이 프로젝트는 MIT 라이선스 하에 배포됩니다. \ No newline at end of file diff --git a/_quarto.yml b/_quarto.yml index a445ba8..c886432 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -28,6 +28,20 @@ website: file: lectures/ch1_lec.qmd - text: "2장. 오픈스택 설치 가이드" file: lectures/ch2_lec.qmd + - section: "6장. Neutron" + contents: + - text: "6장. Neutron 개요" + file: lectures/ch6_lec.qmd + - text: "SNAT/DNAT 개념" + file: lectures/ch6/snat_dnat.qmd + - text: "Neutron Agent 종류 정리" + file: lectures/ch6/neutron_agents.qmd + - text: "OVS/VXLAN 가상 네트워크 만들기" + file: lectures/ch6/ovs_vxlan_vpn.qmd + + + + format: html: diff --git a/custom.scss b/custom.scss index fe03aae..79c9f2b 100644 --- a/custom.scss +++ b/custom.scss @@ -81,4 +81,28 @@ main { .bi { color : white; margin-right: 0.2rem; -} \ No newline at end of file +} + +// ----------------------------------------------------------------------------- +// 코드 블록 / ASCII 다이어그램 스타일 +// ----------------------------------------------------------------------------- + +// 모든 코드 블록에 공통 배경/여백/폰트 적용 (언어와 무관) +pre code, +pre.sourceCode { + display: block; + padding: 0.75rem 1rem; + margin: 1rem 0; + border-radius: 6px; + background-color: #f8f9fb; + font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, 'Courier New', monospace; + font-size: 0.9rem; + line-height: 1.5; + overflow-x: auto; +} + +// ASCII 다이어그램(예: ```text)도 코드 블록처럼 보이도록 별도 보정 +code.language-text, +code.sourceCode.text { + background-color: #f5f5f7; +} \ No newline at end of file diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 1.png b/lectures/ch6/images/ovs_vxlan_vpn/image 1.png new file mode 100644 index 0000000..8e21385 Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 1.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 2.png b/lectures/ch6/images/ovs_vxlan_vpn/image 2.png new file mode 100644 index 0000000..3dfcfce Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 2.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 3.png b/lectures/ch6/images/ovs_vxlan_vpn/image 3.png new file mode 100644 index 0000000..062b66c Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 3.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 4.png b/lectures/ch6/images/ovs_vxlan_vpn/image 4.png new file mode 100644 index 0000000..fcb6cc0 Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 4.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 5.png b/lectures/ch6/images/ovs_vxlan_vpn/image 5.png new file mode 100644 index 0000000..e6673ac Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 5.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 6.png b/lectures/ch6/images/ovs_vxlan_vpn/image 6.png new file mode 100644 index 0000000..63b2021 Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 6.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 7.png b/lectures/ch6/images/ovs_vxlan_vpn/image 7.png new file mode 100644 index 0000000..30b65b6 Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 7.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 8.png b/lectures/ch6/images/ovs_vxlan_vpn/image 8.png new file mode 100644 index 0000000..cafbe40 Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 8.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image 9.png b/lectures/ch6/images/ovs_vxlan_vpn/image 9.png new file mode 100644 index 0000000..7f112ef Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image 9.png differ diff --git a/lectures/ch6/images/ovs_vxlan_vpn/image.png b/lectures/ch6/images/ovs_vxlan_vpn/image.png new file mode 100644 index 0000000..9abe0e8 Binary files /dev/null and b/lectures/ch6/images/ovs_vxlan_vpn/image.png differ diff --git a/lectures/ch6/images/snat_dnat/image 1.png b/lectures/ch6/images/snat_dnat/image 1.png new file mode 100644 index 0000000..084c0ff Binary files /dev/null and b/lectures/ch6/images/snat_dnat/image 1.png differ diff --git a/lectures/ch6/images/snat_dnat/image.png b/lectures/ch6/images/snat_dnat/image.png new file mode 100644 index 0000000..b7ad85b Binary files /dev/null and b/lectures/ch6/images/snat_dnat/image.png differ diff --git a/lectures/ch6/neutron_agents.qmd b/lectures/ch6/neutron_agents.qmd new file mode 100644 index 0000000..c603f05 --- /dev/null +++ b/lectures/ch6/neutron_agents.qmd @@ -0,0 +1,352 @@ +--- +title: "[6-1] Neutron 에이전트 설정 파일 정리" +description: "Neutron 네트워크 서비스를 구성할 때 사용하는 주요 에이전트(dhcp, L3, Linux Bridge, OVS, SR-IOV, metering 등)의 역할과 각 설정 파일(*_agent.ini, ml2_conf.ini, neutron.conf)의 핵심 옵션을 정리합니다." +code: "6-1" +--- + +Neutron 에이전트는 크게 다음과 같이 역할별로 나눌 수 있습니다. + +- **L2 (Layer 2) 관련** + - ‎linuxbridge-agent + - ‎openvswitch-agent + - ‎sriov-agent +- **L3 (Layer 3, 라우팅/NAT) 관련** + - ‎l3-agent +- **서비스 관련** + - ‎dhcp-agent + - ‎metadata-agent + - ‎metering-agent + +Neutron 서버(‎`neutron-server`)가 API 및 데이터베이스를 통해 네트워크/서브넷/포트를 관리한다면, + +각 에이전트들은 실제 컴퓨트/네트워크 노드에서 **브리지 생성, IP 할당, 라우터 구성, 메타데이터 전달** 등 **`“실제 동작”`**을 담당한다고 볼 수 있습니다. + +> 참고: 본 문서는 각 섹션의 "핵심 옵션 요약"만 남겼습니다. 세부 옵션은 배포판/버전별 공식 문서를 참고하세요. + +## [6-1-1] dhcp_agent.ini + +**역할 요약** + +- Neutron 네트워크/서브넷에 대해 **DHCP 서버 역할**을 수행하는 에이전트입니다. +- 일반적으로 ‎**`dnsmasq`** 프로세스를 사용해 인스턴스 부팅 시 IP 주소, 게이트웨이, DNS 등 네트워크 정보를 할당합니다. + - **`dnsmasq`**는 복잡한 설정 없이 **내부 망의 도메인 관리(DNS)와 IP 할당(DHCP)을 한 번에 해결하기 위해 만든 경량급 네트워크 백그라운드 서비스** + +**언제 쓰는지** + +- 인스턴스가 **자동으로 IP를 할당받도록** 하고 싶을 때, 대부분의 OpenStack 환경에서 **기본으로 사용**합니다. +- 각 서브넷에서 DHCP를 사용하도록 설정했을 경우, 해당 서브넷에 대한 DHCP 네임스페이스와 dnsmasq 프로세스를 관리합니다. + +**주요 옵션** + +- ‎**`interface_driver`** + - **역할:** DHCP 네임스페이스를 만들고, 내부 인터페이스를 연결할 때 사용할 L2 드라이버. + - **예시 값:** + - ‎`neutron.agent.linux.interface.OVSInterfaceDriver` + - ‎`neutron.agent.linux.interface.BridgeInterfaceDriver` (linuxbridge 환경) + - **언제 설정하는지:** + - L2 에이전트가 ‎`openvswitch-agent`면 OVS 관련 드라이버, ‎`linuxbridge-agent`면 Bridge 관련 드라이버로 맞춰줍니다. +- **`‎dhcp_driver`** + - **역할:** 실제 DHCP 구현 선택. + - **일반적으로 사용:** ‎`neutron.agent.linux.dhcp.Dnsmasq` + - **비고:** 특별한 요구사항이 없다면 기본 dnsmasq를 그대로 사용하는 경우가 대부분입니다. +- ‎**`enable_isolated_metadata`** + - **값:** ‎`True` / ‎`False` + - **역할:** 라우터가 없는 **isolated 네트워크**에서도 인스턴스가 메타데이터 서비스에 접근할 수 있게 할지 여부. + - **사용 예:** 테스트용 네트워크를 자주 만들고, 라우터 없이도 메타데이터에 접근하도록 하고 싶을 때 ‎`True`로 설정. + + +더 자세한 옵션은 Neutron 공식 설정 문서와 Red Hat Neutron 설정 문서를 참고합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|-------------------------|----------------------------------|---------------------------------------------------------| +| interface_driver | openvswitch, Bridge 등 | DHCP 네임스페이스 안에서 어떤 L2 드라이버를 쓸지 결정합니다. | +| dhcp_driver | neutron.agent.linux.dhcp.Dnsmasq | 실제 DHCP 구현 선택. 특별한 요구가 없으면 기본값을 사용합니다. | +| enable_isolated_metadata| False | 라우터가 없는 isolated 네트워크에서도 메타데이터를 쓸지 여부입니다. | +| force_metadata | False | 라우터 유무와 상관없이 모든 네트워크에 메타데이터를 강제로 제공할지 여부입니다. | +| enable_metadata_network | False | 별도의 메타데이터 전용 네트워크(169.254.169.254/16 등)를 사용할지 여부입니다. | + +## [6-1-2] l3_agent.ini + +역할 요약 + +- Neutron 라우터를 실제 **네임스페이스 기반 L3 라우터**로 구현하고,**라우팅, NAT, 플로팅 IP** 등을 처리하는 에이전트입니다. + +언제 쓰는지 + +- 프로젝트 라우터를 생성하여 **외부 네트워크와 통신**하게 하고 싶을 때 필수입니다. +- 플로팅 IP를 사용하거나, 테넌트 네트워크 간 라우팅이 필요할 때 사용됩니다. +- 일반적으로 네트워크 노드(또는 컨트롤러+네트워크 통합 노드)에 배치됩니다. + +**주요 옵션** + +- ‎**`interface_driver`** + - 역할 및 사용 방식은 ‎`dhcp_agent.ini`와 비슷하게, 라우터 네임스페이스 내 인터페이스 연결 방식을 결정합니다. +- ‎**`agent_mode`** + - **역할:** L3 에이전트가 어떤 모드로 동작할지 결정. + - **예시 값:** + - ‎`legacy`: 고전적인 단일 L3 에이전트 방식 + - ‎`dvr_snat`, ‎`dvr`: DVR(Distributed Virtual Routing) 환경에서 사용 + - **주의:** DVR 구성 여부에 따라 반드시 일관성 있게 설정해야 합니다. +- ‎**`external_network_bridge`** + - **역할:** 외부 네트워크와 연결될 브리지 이름. + - **설정 예:** OVS 환경에서 ‎`br-ex` 등. + - **특징:** 최근 구성에서는 특정 값 대신 provider network + bridge_mappings 조합으로 대체되는 경우도 많습니다. +- ‎**`router_delete_namespaces`** + - **역할:** 라우터 삭제 시 네임스페이스를 자동으로 제거할지 여부. + - **값:** ‎`True` / ‎`False` + +더 자세한 옵션은 Neutron 공식 설정 문서와 Red Hat Neutron 설정 문서를 참고합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|----------------------|-------------|---------------------------------------------------------------------| +| interface_driver | (환경별 상이)| 라우터 네임스페이스 내부 인터페이스를 어떤 L2 드라이버로 붙일지 결정합니다. | +| agent_mode | legacy | L3 에이전트가 중앙집중형인지(DVR/legacy) 등 동작 모드를 결정합니다. | +| external_network_bridge | (빈 값 권장) | 외부 네트워크와 연결되는 브리지 이름. 최근에는 provider network + bridge_mappings로 대체되는 경우가 많습니다. | +| router_delete_namespaces | True/False | 라우터 삭제 시 네임스페이스를 자동으로 정리할지 여부입니다. | +| enable_metadata_proxy| True | 각 라우터 네임스페이스 안에서 메타데이터 프록시를 띄울지 여부입니다. | + +## [6-1-3] linuxbridge_agent.ini + +역할 요약 + +- Linux Bridge를 사용해 가상 스위치를 구성하고, Neutron 포트를 실제 Linux 브리지 및 물리 인터페이스에 매핑하는 L2 에이전트입니다. + +언제 쓰는지 + +- OVS 대신 **Linux Bridge 기반의 네트워크 구성**을 사용할 때. +- 일반적으로 컴퓨트 노드에 배치되어 인스턴스의 vNIC을 Linux Bridge에 붙여줍니다. + +**주요 옵션** + +- ‎**`physical_interface_mappings`** + - **역할:** 논리 네트워크 이름 → 물리 NIC 이름 매핑. + - **예시:** ‎`provider:eth1` + - **의미:** ‎`provider` 네트워크 트래픽은 호스트의 ‎`eth1`을 통해 나감. +- ‎**`bridge_mappings`** (버전/배포판에 따라 영역/형식이 다를 수 있음) + - **역할:** provider 네트워크와 Linux 브리지 이름 매핑. + - **예시:** ‎`provider:br-provider` +- ‎**`enable_vxlan`** + - **역할:** VXLAN 기반의 오버레이 네트워크 사용 여부. + - **값:** ‎`True` / ‎`False` +- ‎**`local_ip`** + - **역할:** VXLAN 터널 엔드포인트 IP 주소. + - **예시:** ‎`local_ip = 10.0.0.10` + +더 자세한 옵션은 아래에서 확인 가능합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|----------------------------|--------------------|-----------------------------------------------------------------| +| physical_interface_mappings| (예: provider:eth1)| 논리 네트워크 이름을 실제 물리 NIC에 매핑합니다. | +| bridge_mappings | (예: provider:br-provider) | provider 네트워크를 어떤 Linux 브리지에 연결할지 결정합니다. | +| enable_vxlan | True/False | VXLAN 오버레이 네트워크 사용 여부입니다. | +| local_ip | 10.0.0.10 등 | VXLAN 터널 엔드포인트로 사용할 호스트의 IP 주소입니다. | +## [6-1-4] metadata_agent.ini + +역할 요약 + +- 인스턴스에서 메타데이터(유저 데이터, 인스턴스 정보 등)를 조회할 수 있도록, +Neutron 네트워크와 Nova 메타데이터 서비스 사이를 **프록시** 해주는 에이전트입니다. + +언제 쓰는지 + +- 인스턴스가 ‎`http://169.254.169.254/` 로 메타데이터에 접근할 수 있어야 할 때 필수입니다. + - [http://169.254.169.254/](http://169.254.169.254/) 는 전 세계 모든 클라우드(AWS, GCP, OpenStack 등)에서 공통으로 사용하는 **메타데이터 서버 주소** + - 받아오는 메타데이터 정보는 호스트네임, SSH 키와 같은 것들(다 같이 가지고 있지만, 각 인스턴스마다 값이 다를 수 있는 것들) + - **즉 "이미지 하나로 수천 대의 서로 다른 서버를 찍어내기 위해"** 메타데이터가 필요하고 이를 관리하는 것이 필요하다. +- 대부분의 환경에서 기본적으로 사용합니다. + +**주요 옵션** + +- ‎**`nova_metadata_host`** + - **역할:** Nova 메타데이터 API가 떠 있는 호스트 주소. + - **예시:** ‎`nova_metadata_host = controller.example.com` 또는 컨트롤러 IP. +- ‎**`nova_metadata_port`** + - **역할:** Nova 메타데이터 API 포트. + - **기본 예시:** ‎`8775` 등 배포판에 따라 상이. +- ‎**`metadata_proxy_shared_secret`** + - **역할:** Neutron metadata agent와 Nova 메타데이터 서비스 간 인증에 사용되는 공유 비밀값. + - **주의:** Nova 측 설정(‎`metadata_proxy_shared_secret`)과 반드시 일치해야 합니다. + +더 자세한 옵션은 아래에서 확인 가능합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|--------------------------|-----------------------------------|-------------------------------------------------------------------| +| nova_metadata_host | controller.example.com 등 | Nova 메타데이터 API가 떠 있는 호스트 이름 또는 IP입니다. | +| nova_metadata_port | 8775 | Nova 메타데이터 API 포트 번호입니다. | +| metadata_proxy_shared_secret | (환경에서 지정) | Neutron metadata agent와 Nova 간 인증에 사용하는 공유 시크릿입니다. | +| auth_url/auth_type 등 | (Keystone 설정 값) | Keystone 인증 정보를 통해 메타데이터 서비스와 통신할 때 사용합니다. | +## [6-1-5] openvswitch_agent.ini + +역할 요약 + +- Open vSwitch(OVS)를 이용해 가상 스위치를 구성하고, +Neutron 네트워크를 OVS 브리지/포트로 매핑하는 L2 에이전트입니다. + +언제 쓰는지 + +- **OVS 기반 네트워크**를 사용할 때. +- VXLAN, GRE 등 터널 네트워크를 활용하는 환경에서 가장 많이 사용되는 에이전트입니다. +- 주로 컴퓨트 노드와 네트워크 노드에 배치됩니다. + +주요 옵션 + +- ‎**`local_ip`** + - **역할:** 터널 네트워크의 엔드포인트 IP. + - **예시:** ‎`local_ip = 10.0.0.10` +- ‎**`tunnel_types`** + - **역할:** 사용할 터널 종류 지정. + - **예시 값:** ‎`vxlan`, ‎`gre`, ‎`geneve` 등 + - **예시:** ‎`tunnel_types = vxlan` +- ‎**`bridge_mappings`** + - **역할:** provider 네트워크 → OVS 브리지 매핑. + - **예시:** ‎`provider:br-provider` + - **의미:** ‎`provider` 타입 네트워크는 호스트의 ‎`br-provider` 브리지를 통해 외부와 연결. +- ‎**`of_interface`** / ‎**`ovsdb_interface`** + - **역할:** OVS와 통신할 때 사용할 인터페이스(라이브러리) 유형. + - **일반적으로:** 기본값 사용. + +더 자세한 옵션은 아래에서 확인 가능합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|-------------------|--------------------------|-------------------------------------------------------------------| +| local_ip | 10.0.0.10 등 | VXLAN/GRE/Geneve 터널의 로컬 엔드포인트 IP입니다. | +| tunnel_types | vxlan, gre, geneve 등 | 어떤 종류의 터널 네트워크를 사용할지 결정합니다. | +| bridge_mappings | provider:br-provider 등 | provider 네트워크를 어떤 OVS 브리지에 연결할지 매핑합니다. | +| integration_bridge| br-int | 테넌트 포트가 연결되는 통합 브리지 이름입니다. | +| tunnel_bridge | br-tun | 터널 인터페이스가 붙는 브리지 이름입니다. | + +## [6-1-6] sriov_agent.ini + +역할 요약 + +- SR-IOV NIC를 사용하여, 인스턴스에 **가상 기능(VF)** 를 직접 할당하는 L2 에이전트입니다. +- 고성능/저지연 네트워크가 필요한 워크로드에서 사용합니다. + +언제 쓰는지 + +- 호스트에 SR-IOV 지원 NIC가 있고, 인스턴스에 **직접 VF를 붙여야 할 때** 사용합니다. +- 일반적인 가상 스위치(Linux Bridge, OVS) 경로를 우회하여 네트워크 성능을 끌어올리는 경우에 적용합니다. + +주요 옵션 (개요 수준) + +- ‎**`physical_device_mappings`** + - **역할:** 논리 네트워크 이름 → 물리 SR-IOV NIC 매핑. + - **예시:** ‎`physnet1:eth2` + - **의미:** ‎`physnet1` 네트워크는 호스트의 ‎`eth2` SR-IOV NIC를 사용. +- ‎**`exclude_devices`** + - **역할:** SR-IOV 관리 대상에서 제외할 VF 목록. + - **예시:** ‎`eth2:0000:03:10.1;0000:03:10.2` + - **사용 이유:** 특정 VF는 호스트 용도로 예약하거나, 다른 용도로 사용할 때. + +더 자세한 옵션은 아래에서 확인 가능합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|--------------------------|---------------------------|----------------------------------------------------------------| +| physical_device_mappings | physnet1:eth2 등 | 논리 네트워크(physnet)를 어느 SR-IOV 물리 NIC(PF)에 매핑할지 정의합니다. | +| exclude_devices | eth2:0000:03:10.1;... 등 | SR-IOV 관리 대상에서 제외할 VF 목록입니다. | +| polling_interval | 2 | SR-IOV 디바이스 상태를 얼마나 자주 폴링할지(초 단위) 결정합니다. | +| report_interval | 30 | Neutron 서버에 상태를 얼마나 자주 보고할지(초 단위) 결정합니다. | + +## [6-1-7] metering_agent.ini + +역할 요약 + +- Neutron 라우터를 기준으로 **트래픽 사용량(미터링 정보)** 을 수집하는 에이전트입니다. +- 대역폭 사용량, 과금/통계 등의 용도로 활용할 수 있는 데이터를 제공합니다. + +언제 쓰는지 + +- 테넌트별/라우터별 트래픽 사용량을 기록해야 하는 환경에서 사용합니다. +- 미터링/과금/리포팅 기능을 활성화할 때 유용합니다. + +주요 옵션 (개요 수준) + +- ‎**`driver`** + - **역할:** 어떤 미터링 드라이버를 사용할지 지정. + - **예시:** ‎`neutron.services.metering.drivers.iptables.iptables_driver.IptablesMeteringDriver` 등 +- ‎**`interface_driver`** + - **역할:** l3/dhcp와 마찬가지로 네임스페이스 내 인터페이스를 관리하기 위한 드라이버. + - **예시:** OVS 환경이면 ‎`neutron.agent.linux.interface.OVSInterfaceDriver` +- ‎**`measure_interval`** + - **역할:** 트래픽을 측정하는 간격(초 단위). + - **예시:** ‎`measure_interval = 30` +- ‎**`report_interval`** + - **역할:** 수집된 미터링 데이터를 보고(전송)하는 간격. + +더 자세한 옵션은 아래에서 확인 가능합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 기본값(예시) | 역할/언제 중요한지 | +|-------------------|--------------------------------------------------------|------------------------------------------------------------| +| driver | iptables 기반 드라이버 등 | 어떤 방식으로 트래픽을 계측(미터링)할지 결정합니다. | +| interface_driver | OVSInterfaceDriver 등 | L3/DHCP와 마찬가지로 네임스페이스 내부 인터페이스를 관리합니다. | +| measure_interval | 30 | 트래픽을 얼마나 자주 측정할지(초 단위) 결정합니다. | +| report_interval | (환경에 따라 설정) | 수집된 미터링 데이터를 얼마나 자주 보고(전송)할지 결정합니다. | + +## [6-1-8] ml2_conf.ini + +- **역할:** ML2(플러그형 L2 플러그인)의 동작을 정의하는 핵심 설정 파일입니다. +- **주요 내용:** + - 사용할 네트워크 타입: ‎`type_drivers` (예: ‎`local,flat,vlan,vxlan`) + - 테넌트 네트워크에 허용할 타입: ‎`tenant_network_types` + - 사용할 메커니즘 드라이버: ‎`mechanism_drivers` (예: ‎`openvswitch`, ‎`linuxbridge`, ‎`sriovnicswitch`) + - 물리 네트워크와 실제 인터페이스/브리지 매핑: ‎`ml2_type_flat`, ‎`ml2_type_vlan` 섹션 등. + +이 파일에서 “어떤 L2 기술/드라이버를 쓸지”를 결정하고, + +각 호스트에서는 해당하는 에이전트(linuxbridge-agent, openvswitch-agent, sriov-agent 등)가 실제 동작을 담당합니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 예시 값 | 역할/언제 중요한지 | +|-----------|---------|--------------------| +| type_drivers | local,flat,vlan,vxlan,geneve | 지원할 L2 네트워크 타입 전체를 선언합니다. | +| tenant_network_types | vxlan,vlan,flat,local | 테넌트 네트워크에 허용할 타입을 제한합니다. | +| mechanism_drivers | openvswitch,linuxbridge,sriovnicswitch,ovn | 실제 L2 구현(바인딩) 드라이버를 선택합니다. | +| extension_drivers | port_security,qos,trunk | 추가 기능(보안그룹, QoS, Trunk 등)을 활성화합니다. | +| ml2_type_flat.flat_networks | provider,public | flat 네트워크로 허용할 물리 네트워크(physnet) 목록입니다. | +| ml2_type_vlan.network_vlan_ranges | provider:100:200 | VLAN에 사용할 physnet별 VLAN ID 범위입니다. | +| ml2_type_vxlan.vni_ranges | 10:10000 | VXLAN VNI 범위를 지정합니다. | + +## [6-1-9] neutron.conf + +- **역할:** Neutron 서비스 전체에 공통적으로 적용되는 설정 파일입니다. +- **대표적으로 포함되는 항목:** + - 사용 플러그인: ‎`core_plugin`, ‎`service_plugins` + - 데이터베이스 연결 정보: ‎`connection` + - 메시지 큐(RabbitMQ 등) 설정 + - Keystone 인증 관련 설정(‎`[keystone_authtoken]` 섹션) + - 로그/디버그/프로세스 관련 옵션 등 + +에이전트별 ‎`*.ini` 파일이 “각 에이전트의 역할과 옵션”을 정의한다면, + +‎`neutron.conf`는 Neutron 전체 프로세스의 **공통 기반 설정**을 제공한다고 보면 됩니다. + +### 핵심 옵션 요약 + +| 옵션 이름 | 예시 값 | 역할/언제 중요한지 | +|-----------|---------|--------------------| +| core_plugin | neutron.plugins.ml2.plugin.Ml2Plugin | 코어 플러그인(대부분 ML2)을 지정합니다. | +| service_plugins | router,metering,qos,trunk | 추가 서비스 플러그인들을 활성화합니다. | +| [database] connection | mysql+pymysql://user:pass@host/neutron | Neutron DB 연결 문자열입니다. | +| transport_url (또는 [oslo_messaging_rabbit]) | rabbit://user:pass@mq:5672/ | 메시지 버스(RabbitMQ 등) 연결을 지정합니다. | +| allow_overlapping_ips | true | 테넌트 네트워크 간 중복 IP 대역 허용 여부입니다. | +| notify_nova_on_port_* | true | 포트 상태/데이터 변경 시 Nova에 알림을 보냅니다. | +| auth_strategy | keystone | API 인증 전략(Keystone 사용)입니다. | + +# 참조 + +[Red Hat OSP 16.2 Neutron 설정 레퍼런스 — 에이전트, ML2, OVS, SR-IOV, neutron.conf 옵션 상세](https://docs.redhat.com/ko/documentation/red_hat_openstack_platform/16.2/html/configuration_reference/neutron_2) diff --git a/lectures/ch6/ovs_vxlan_vpn.qmd b/lectures/ch6/ovs_vxlan_vpn.qmd new file mode 100644 index 0000000..af15e86 --- /dev/null +++ b/lectures/ch6/ovs_vxlan_vpn.qmd @@ -0,0 +1,234 @@ +--- +title: "[6-15] OVS와 VXLAN으로 가상 네트워크 구성하기" +description: "여러 VM에 Open vSwitch(OVS)를 설치하고 VXLAN 터널을 구성하여, 물리적으로 떨어진 노드들 사이에 VPN과 유사한 가상 L2 네트워크를 만드는 방법과 트러블슈팅 포인트를 정리합니다." +code: "6-15" +--- + +# [6-15-1] 요구사항 + +- VM 세 개를 만들어서 각각 가상 NIC를 100,200,300으로 만들어서 OVS를 설치하고 host에서 ping으로 + - 가상 네트워크에다가 ping이 가도록. + - 가상 NIC끼리 서로 통신이 되도록 만드는 것. + +⇒ OVS로 VPN 서비스를 만든다(VXLAN으로). + +**VM 3개를 만들고 OVS와 VXLAN으로 VPN 같은 가상 네트워크를 만들어 보기** + +# [6-15-2] 구축 + +```bash +sudo apt update +sudo apt install -y openvswitch-switch + +sudo systemctl status openvswitch-switch +``` + +각 노드마다 아래의 명령어를 실행해줍니다. + +![그림 6-15-2-1. 노드 브리지 추가 시각화](images/ovs_vxlan_vpn/image.png) + +```bash +sudo ovs-vsctl add-br br0 +``` + +`br0`라는 이름의 가상 브릿지(가상 스위치)를 하나 만듭니다. + +```bash +sudo ip addr add 10.0.0.100/24 dev br0 +``` + +생성한 가상 스위치(`br0`) 자체에 `10.0.0.100`이라는 관리용 IP 주소를 부여합니다. + +```bash +sudo ip link set br0 up +``` + +가상 스위치(`br0`)를 활성화(ON) 상태로 바꿉니다. + +```bash +sudo ovs-vsctl add-port br0 vx-vm2 -- set interface vx-vm2 type=vxlan options:remote_ip=172.31.0.230 options:key=10 +sudo ovs-vsctl add-port br0 vx-vm3 -- set interface vx-vm3 type=vxlan options:remote_ip=172.31.0.231 options:key=10 +``` + +물리적으로 떨어져 있는 호스트들을 가상의 터널로 잇는 과정입니다. + +- **`add-port br0 vx-vm2`**: `br0` 스위치에 `vx-vm2`라는 가상의 포트(구멍)를 하나 뚫습니다. +- **`type=vxlan`**: 이 포트에 꽂을 케이블의 종류를 **VXLAN**이라는 가상 터널 기술로 지정합니다. + +**`options:remote_ip=172.31.0.230`** + +- 이 터널 케이블의 반대쪽 끝이 연결된 **물리적인 상대방 주소**입니다. +- 실제 연결하고자 하는 노드의 IP를 의미합니다. + +**`options:key=10`** + +- VXLAN의 **VNI(Virtual Network Identifier)** 값입니다. +- 일종의 '채널 번호'입니다. 상대방과 내가 똑같이 `key=10`으로 맞춰야만 같은 네트워크로 인식합니다. 만약 다른 팀이 `key=20`을 쓰고 있다면, 같은 선을 공유하더라도 서로 데이터를 볼 수 없습니다 + +위와 같이 구성을 진행하시면 아래 그림처럼 연결됩니다. + +- Full Mesh 구조입니다 + +![그림 6-15-2-2. 구축한 Full Mesh 구조](images/ovs_vxlan_vpn/image 1.png) + +# [6-15-3] 트러블 슈팅 + +위의 구조에서 Node1 → Node2로 설정한 IP인 10.0.0.200/24로 핑을 날렸을 때 문제가 발생했습니다. + +방화벽이나 네트워크 설정이 의도한 대로 이미 잘 되어 있는 것을 확인했고, 그럼에도 되지 않아 네트워크를 구성할 때부터 문제가 생겼음을 알 수 있었습니다. + +다음 명령어를 통해 어떤 문제가 발생했는지를 살펴봅니다. + +```bash +sudo tcpdump -i ens18 udp port 4789 +``` + +- VXLAN에서는 4789 포트에서 터널링을 위한 처리가 이뤄지고 UDP로 감싸진 채로 처리됩니다. + +다음과 같이 ARP Flood가 일어나고 있음을 볼 수 있었습니다 + +```bash +ARP, Reply vxlan-2 is-at 5a:49:50:c8:7d:4f (oui Unknown), length 28 + +23:51:51.818049 IP 172.31.0.229.40188 > vxlan-2.4789: VXLAN, flags [I] (0x08), vni 10 + +ARP, Reply vxlan-2 is-at 5a:49:50:c8:7d:4f (oui Unknown), length 28 + +23:51:51.818049 IP 172.31.0.229.40188 > vxlan-2.4789: VXLAN, flags [I] (0x08), vni 10 + +ARP, Reply vxlan-2 is-at 5a:49:50:c8:7d:4f (oui Unknown), length 28 + +23:51:51.818049 IP 172.31.0.229.40188 > vxlan-2.4789: VXLAN, flags [I] (0x08), vni 10 + +ARP, Reply vxlan-2 is-at 5a:49:50:c8:7d:4f (oui Unknown), length 28 + +23:51:51.818068 IP 172.31.0.229.40188 > vxlan-2.4789: VXLAN, flags [I] (0x08), vni 10 + +ARP, Reply vxlan-2 is-at 5a:49:50:c8:7d:4f (oui Unknown), length 28 +``` + +ARP Flood가 왜 일어날까요? + +![그림 6-15-3-1. ARP Flood 발생 상황 예시](images/ovs_vxlan_vpn/image 2.png) + +1. VM-A가 동일한 VNI(VXLAN Network Identifier)에 있는 VM-B와 통신하고 싶어 합니다. 하지만 VM-B의 MAC 주소를 모르기 때문에 **ARP Request**를 보냅니다. +2. ARP Request는 브로드캐스트 패킷입니다. VXLAN에서 브로드캐스트, 멀티캐스트, 알 수 없는 유니캐스트(BUM)는 모든 목적지에 전달되어야 합니다. +3. Full-mesh 구조에서 특정 VTEP은 자신이 알고 있는 모든 상대방 VTEP(Peer) 목록을 가지고 있습니다. +- 브로드캐스트 패킷이 들어오면, VTEP은 이 패킷을 **복제**하여 리스트에 있는 **모든 VTEP에게 유니캐스트로 쏩니다.** +- 참여하는 VTEP 노드가 많아질수록 단 하나의 ARP 요청이 수많은 복제 패킷을 만들어 네트워크 전체에 뿌려지게 됩니다. → Flood가 발생합니다 + +```bash +sudo ovs-vsctl set bridge br0 stp_enable=true +``` + +이를 해결하기 위해서 stp 설정을 각 노드에서 진행해줍니다. + +- **`stp_enable=true`**는 **Spanning Tree Protocol을 활성화**하겠다는 뜻입니다. + +**Spanning Tree Protocol는 뭘까요?** + +![그림 6-15-3-2. STP를 활용한 루프 방지 개념](images/ovs_vxlan_vpn/image 3.png) + +- STP의 역할은 루프 방지입니다. 네트워크에 경로가 여러 개 있을 때, 패킷이 한 방향으로 가지 않고 뱅글뱅글 도는 '루프' 현상이 발생하면 네트워크는 즉시 마비됩니다. +- STP는 이 루프를 감지해서 특정 경로를 논리적으로 차단(Blocking)했다가, 주 경로가 끊기면 다시 여는 역할을 합니다. + - STP가 동작하면 위와 같이 물리적으로 루프 구조인 네트워크에서 특정 포트를 차단 상태로 바꾸어 논리적으로 루프가 발생하지 않게 됩니다. + +설정 후 들어오는 트래픽에 대해서도 확인해 보면 VNI 10으로 설정한 경우 정상적으로 오고 갑니다. + +```bash +sudo tcpdump -i ens18 udp port 4789 +``` + +![그림 6-15-3-3. STP 적용 후 VXLAN 트래픽 예시 1](images/ovs_vxlan_vpn/image 4.png) + +![그림 6-15-3-4. STP 적용 후 VXLAN 트래픽 예시 2](images/ovs_vxlan_vpn/image 5.png) + +![그림 6-15-3-5. STP 적용 후 VXLAN 트래픽 예시 3](images/ovs_vxlan_vpn/image 6.png) + +잘 보내집니다. + +# [6-15-4] 생각해볼 부분들 + +**stp 말고는 ARP Flood를 막을 수 있는 방법이 없는가?** + +- stp는 BPDUs 패킷을 주고받아 루프를 감지하고 특정 포트를 **물리적으로 차단하는 방식이므로 대역폭을 낭비할 수 있고, 장애 시 재계산 시간 필요합니다.** + +→ OVS Group Table (all type)로 처리도 가능 + +- 트래픽을 복제할 대상을 미리 정해두고, **논리적으로 지정된 곳으로만 전송합니다** +- Group Table은 애초에 **'들어온 포트로 다시 나가지 않게'** 하거나 **'허가된 터널로만 나가게'** 설계하므로 논리적인 루프 자체가 발생하지 않도록 제어합니다. + +**full mesh를 기반으로해서 구현하는 것이 올바른가?** + +작은 규모라면 상관 없지만, 규모가 커지면 관리가 어려운 것이 사실입니다 + +그래서 Full-mesh의 flooding 오버헤드를 피하기 위해 L2Pop과 EVPN-VXLAN 같은 컨트롤 플레인 기반 방식도 쓴다고 합니다. + +## L2 Population (L2Pop) + +![그림 6-15-4-1. L2 Population(L2Pop) 동작 개념 1](images/ovs_vxlan_vpn/image 7.png) + +![그림 6-15-4-2. L2 Population(L2Pop) 동작 개념 2](images/ovs_vxlan_vpn/image 8.png) + +- Neutron ML2 플러그인 + OVS에서 쓰는 메커니즘 드라이버로, 중앙 Neutron 서버가 VM의 MAC/IP 위치를 모든 노드에 RPC로 미리 푸시합니다. +- BUM(Broadcast/Unknown/Multicast) 트래픽을 **unicast source replication**으로 바꿔 전체 flooding 대신 "대상 노드 하나로만" 패킷 전송. +- ARP responder도 추가해 로컬에서 ARP reply 생성, 브로드캐스트를 아예 막음. OVS flow 테이블에 prepopulate. + +## EVPN-VXLAN (BGP EVPN) + +![그림 6-15-4-3. BGP EVPN-VXLAN 제어 플레인 개념](images/ovs_vxlan_vpn/image 9.png) + +- BGP EVPN 컨트롤 플레인으로 VTEP(Leaf 스위치 등) 간 MAC/IP 정보를 자동 동기화합니다. Type-2 route로 엔드포인트 학습합니다. +- **ARP Suppression**: VTEP이 이미 BGP에서 알던 MAC/IP로 ARP request를 로컬 proxy reply. Flooding 최소화. +- Scale-out 강점: 노드 추가 시 BGP가 자동 피어링·라우팅 업데이트. 멀티테넌트 VNI별 관리 쉬움. + +라고 설명하는데, 개념이 다소 어렵게 느껴질 수 있습니다. 아래 내용은 제미나이에게 질의하여 보다 이해하기 쉽게 정리한 예시입니다. + +VM-B가 `VTEP-2`에 생성되자마자, `VTEP-2`는 중앙 전산소인 **RR**에게 이 소식을 알립니다. + +```yaml +[ VM-B (10.0.1.2) ] ----> [ VTEP-2 ] ----------------> [ BGP RR (전산소) ] + (이사 완료) (우체국2) "신규 입주 신고!" (정보 수집) + (BGP Update) +``` + +- **VTEP-2의 메시지:** "전산소님, 제 아래에 IP `10.0.1.2`, MAC `BB:BB`를 가진 VM-B가 들어왔습니다!" + +중앙 전산소(RR)는 받은 정보를 데이터베이스에 저장하고, 자신에게 연결된 모든 Client(우체국들)에게 이 정보를 **반사(Reflection)**합니다. + +```yaml + [ BGP RR (전산소) ] + / | \ + (반사) / | \ (반사) + v v v + [ VTEP-1 ] [ VTEP-2 ] [ VTEP-3 ]` +``` + +- **RR의 방송:** "모두 주목! 이제부터 `10.0.1.2`로 가는 편지는 `VTEP-2`(우체국2)로 보내면 된다. 다들 자기 수첩에 적어놔!" +- **결과:** 모든 VTEP은 통신이 시작되기도 전에 **VM-B의 위치를 이미 알게 됩니다.** (Control Plane Learning) + +이제 VM-A가 VM-B에게 처음으로 데이터를 보냅니다. 예전 같으면 "누가 VM-B야?"라고 소리를 질렀겠지만, 이제는 다릅니다. + +```yaml +1. [ VM-A ] ----> "10.0.1.2(VM-B) 누구야?" (ARP Request) ----> [ VTEP-1 ] + +2. [ VTEP-1 ] (수첩 확인): + "잠깐, 소리 지를 필요 없어! 전산소에서 아까 말해줬지. + 10.0.1.2는 우체국2(VTEP-2)에 있고, MAC은 BB:BB야." [ARP Suppression] + +3. [ VTEP-1 ] ---- (VXLAN 터널) ----> [ VTEP-2 ] ----> [ VM-B ] +``` + +- **VTEP-1**은 ARP 요청을 네트워크 전체에 뿌리지(Flood) 않고, 자기가 알고 있는 정보로 VM-A에게 바로 답장해 줍니다. +- 데이터 패킷은 정확히 **VTEP-2로만 1:1 유니캐스트**로 전달됩니다. + +**즉 두 가지 방식 모두 하나의 큰 소프트웨어를 두고, +특정 VTEP이 생성됐을 때 이를 감지하고 이 VTEP에 대한 네트워크 정보를 나눠 주는 주체를 두어 처리한다는 점이 동일한 것 같습니다.** + +- **"중앙에서 정보를 관리하고 배포하는 주체"**를 둬서, 전통적인 이더넷의 **"소문내서 찾기(Flood & Learn)"** 방식을 **"미리 알고 알려주기(Push & Direct)"** 방식으로 변경 + +# 참조 + +[STP(Spanning Tree Protocol) 기본 개념과 사용 이유 정리 글](https://kujung.tistory.com/entry/STP%EC%8A%A4%ED%8C%A8%EB%8B%9D-%ED%8A%B8%EB%A6%AC-%ED%94%84%EB%A1%9C%ED%86%A0%EC%BD%9C%EC%9D%98-%EA%B8%B0%EB%B3%B8-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0#google_vignette) + +[OVS/VXLAN 및 STP 관련 추가 설명 블로그 글](https://blog.innern.net/40) \ No newline at end of file diff --git a/lectures/ch6/snat_dnat.qmd b/lectures/ch6/snat_dnat.qmd new file mode 100644 index 0000000..9f2dd9d --- /dev/null +++ b/lectures/ch6/snat_dnat.qmd @@ -0,0 +1,310 @@ +--- +title: "[6-12] SNAT, DNAT 개념 정리" +description: "IPv4 주소 절약과 주소 은닉을 위해 사용하는 NAT의 기본 원리와, 그중에서도 SNAT과 DNAT가 각각 언제 어디에서 어떤 방식으로 동작하는지 정리합니다." +code: "6-12" +--- + +일단 SNAT, DNAT를 설명하기 전에 NAT에 대해서 먼저 설명하겠습니다. + +**NAT은 “여러 사설 IP를 적은 수(또는 한 개)의 공인 IP로 바꿔서 외부와 통신하게 해 주는 주소 변환 장치/기능”입니다.** + +# [6-12-1] NAT가 왜 생겼는지 + +원래 IP 주소는 전 세계에서 고유해야 하는데, 인터넷 사용자가 폭발적으로 늘면서 IPv4 주소가 모자라기 시작했습니다. + +동시에: + +- 내부 망 구조를 자주 바꾸더라도, 외부에 보이는 주소 체계는 그대로 두고 싶고 +- 내부 IP를 외부에 노출하고 싶지 않은(프라이버시·보안 측면) 요구도 있었습니다. + +그래서 내부에서는 마음대로 쓸 수 있는 사설 IP(예: 10.x.x.x, 192.168.x.x)를 쓰고, 외부와 통신할 때만 **NAT 장비가 사설 IP ↔ 공인 IP로 바꿔주는** 구조가 등장했습니다. + +# [6-12-2] NAT의 기본 동작 개념 + +NAT는 일반적으로 **경계 라우터**(회사/집 안과 인터넷 사이)에 위치합니다. + +1. 내부 호스트에서 인터넷으로 패킷을 보냄 (출발지 = 사설 IP). +2. NAT 라우터가 패킷을 보고, 출발지 IP(그리고 필요하면 포트)를 **공인 IP(그리고 새 포트)**로 바꿉니다. +3. 외부 서버 입장에서는 “NAT 라우터의 공인 IP에서 온 트래픽”으로 보입니다. +4. 응답 패킷이 돌아오면, NAT 라우터가 미리 기억해 둔 매핑 정보를 보고 다시 사설 IP(원래 호스트/포트)로 되돌린 후 내부에 전달합니다. + +이때 매핑 정보(어느 내부 IP/포트가 어느 공인 IP/포트로 나갔는지)를 세션별로 가지고 있기 때문에, NAT 장비가 고장나거나 경로가 바뀌면 세션이 끊어지기 쉽습니다. + +# [6-12-3] 두 가지 전통적인 NAT 방식 + +“Traditional NAT”는 크게 두 종류입니다. + +### Basic NAT (주소만 변환) + +- 여러 개의 사설 IP ↔ 여러 개의 공인 IP를 1:1 또는 N:M으로 매핑 +- 패킷 변환 시 **IP 주소만 바꿈** (TCP/UDP 포트는 그대로) +- 내부 호스트 수 ≤ 공인 IP 수라면, 각 내부 호스트가 공인 IP 하나씩을 “빌려 쓰는 느낌” + +예: + +- 내부: 10.0.0.0/16 +- 외부: 198.76.29.0/24 +- 10.0.1.5 → 198.76.29.7 이런 식으로 매핑 + +장점: 구조가 단순하고, 일부 프로토콜/애플리케이션에 덜 민감합니다 + +단점: 공인 IP를 여러 개 써야 해서 주소 절약 효과가 크지 않습니다 + +### NAPT (Network Address Port Translation, 포트까지 변환) + +우리가 흔히 말하는 “NAT”라고 하면 대부분 **NAPT**을 의미합니다. + +- 여러 사설 IP가 **하나의 공인 IP**를 공유 +- (사설 IP, 사설 포트) → (공인 IP, 공인 포트) 튜플로 매핑 +- 내부 수십 대 PC가 하나의 공인 IP로 동시에 인터넷 접속 가능 + +![그림 6-12-3-1. NAPT 동작 원리](images/snat_dnat/image.png) + +**Outbound: 사설 망에서 외부로 나갈 때** + +내부 호스트들이 외부 서버로 요청을 보낼 때, NAT 라우터는 고유한 식별을 위해 **공인 IP 포트(Public Port)**를 새로 할당합니다. + +- **매핑 과정**: 내부 호스트 `192.10.100.1`이 포트 `2000`으로 요청을 보내면, 라우터는 이를 공인 IP `10.16.2.10`의 포트 `1001`로 변환하여 기록합니다. +- **튜플(Tuple) 생성**: 테이블에는 `(사설 IP:포트) ↔ (공인 IP:변환 포트)` 쌍이 생성되며, 이를 통해 하나의 공인 IP로 수많은 내부 장치를 구분할 수 있게 됩니다. + +**Inbound: 외부에서 응답이 돌아올 때** + +외부 서버는 응답을 보낼 때 목적지를 NAT 라우터의 공인 IP와 변환된 포트(`10.16.2.10:2001`)로 설정합니다. + +- **역추적**: NAT 라우터는 도착한 패킷의 포트 번호(`1001`)를 **NAT Table**에서 조회합니다. +- **최종 전달**: 매핑된 정보를 확인한 후, 패킷의 목적지 주소를 다시 원래의 사설 주소인 `192.10.100.1:2000`으로 되돌려 내부 호스트에게 정확히 전달합니다. + +# [6-12-4] NAT의 장점과 한계 + +**장점** + +- IPv4 주소 부족을 완화 (여러 사설 IP → 적은 수의 공인 IP). +- 내부 주소 체계 변경을 외부에 숨길 수 있어, ISP 변경·망 재구성 시에도 외부 영향 최소화. +- 어느 정도의 **프라이버시**: 외부에서는 내부 개별 호스트 주소를 알 수 없습니다. + +**한계·문제점** + +- **End-to-end 특성 붕괴**: IP 주소가 더 이상 “고정된 종단 식별자”가 아니게 됩니다. + - IPSec 같은 “끝단끼리 직접 인증/암호화” 모델과 충돌. +- 디버깅/보안 추적이 어려움: 로그를 NAT 매핑과 함께 봐야 “어느 내부 호스트”인지 알 수 있습니다. +- 세션 상태(state)를 NAT가 들고 있어서, + - NAT 장비가 죽거나 + - 세션이 다른 NAT로 우회되면 → 기존 연결이 깨집니다 +- 일부 프로토콜은 추가 장치(ALG) 없이는 잘 안 돌아가거나, 아예 설계상 NAT-unfriendly. + +## [6-12-5] 실제 어디서 쓰이는지 + +- 집 공유기, 카페·회사 AP: 거의 전부 NAPT 방식 NAT +- 기업 경계 라우터: 사설망(10.x, 192.168.x) ↔ 인터넷 사이의 주소 변환 +- IDC/클라우드 내부: 특정 세그먼트 간, 또는 DMZ 구성 시도 NAT를 사용하기도 함 + +정리하면, **NAT = 내부 사설 주소 체계를 외부에서 가리고, 부족한 IPv4 주소를 아끼기 위해 경계에서 IP(그리고 포트)만 바꿔 주는 중간 통역 장치**라고 보면 됩니다. + +--- + +SNAT과 DNAT은 “어느 주소를 바꾸느냐”와 “언제/어디에서 바꾸느냐”가 핵심입니다. + +![그림 6-12-5-1. SNAT/DNAT 동작 원리](images/snat_dnat/image 1.png) + +# [6-12-6] SNAT (Source NAT) + +SNAT은 **패킷의 출발지(소스) 주소를 바꾸는 것**입니다. + +- 언제/어디서: ‎`POSTROUTING` 체인에서, 패킷이 나가기 직전에 수행됩니다. +그래서 라우팅·필터링 같은 건 모두 원래 주소를 보고 처리한 뒤, 마지막에 주소만 갈아끼웁니다. +- 용도: 사설 IP 여러 대가 하나의 공인 IP로 인터넷에 나갈 때 등, +“내가 누구인 척 할지(출발 IP)”를 바꿔야 할 때 사용합니다. +- iptables 예시 (2.4 기준): + - ‎`-t nat -A POSTROUTING -o eth0 -j SNAT --to 1.2.3.4` +- Masquerade: + - 동적 IP(ADSL, PPPoE, 일반 모뎀 등)에서 쓰는 SNAT의 특수 버전. + - ‎`--to-source`에 IP를 안 적고, **나가는 인터페이스의 IP**를 자동으로 사용. + - 연결이 끊겼다가 새 IP로 다시 붙을 때, 기존 연결들을 정리해 줘서 깔끔함. + - 예: ‎`-j MASQUERADE` + +요약하면, SNAT = “**밖으로 나갈 때 내 IP(소스)를 뭘로 보이게 할까?**”를 설정하는 것. + +# [6-12-7] DNAT (Destination NAT) + +DNAT은 **패킷의 목적지(데스티네이션) 주소를 바꾸는 것**입니다. + +- 언제/어디서: ‎`PREROUTING` 체인에서, 패킷이 들어오자마자 수행됩니다. +그래서 이후 라우팅·필터링은 이미 바뀐 목적지 기준으로 동작합니다. +- 용도: 포트 포워딩, 로드 밸런싱, 내부 서버로 트래픽 보내기 등 +“이 패킷이 실제로 어디로 가야 할지(목적지 IP/포트)”를 바꿀 때 사용합니다. +- iptables 예시: + - ‎`-t nat -A PREROUTING -i eth0 -j DNAT --to 5.6.7.8` + - 웹 트래픽 포트 변경: ‎`--dport 80 ... -j DNAT --to 5.6.7.8:8080` +- REDIRECT: + - DNAT의 특수 버전으로, **들어온 인터페이스 자신의 주소**로 보내는 편의 기능. + - 로컬의 프록시(예: squid)로 투명 프록시 구성할 때 사용. + - 예: ‎`-j REDIRECT --to-port 3128` + +요약하면, DNAT = “**들어오는 패킷을 실제로 어디 서버로 보낼까? (목적지 변경)**”를 정하는 것. + +정리 한 줄로 말하면, + +- SNAT: 나갈 때 “**누가 보낸 것처럼 보이게 할지**” (Source, POSTROUTING) +- DNAT: 들어올 때 “**어디로 보내 줄지**” (Destination, PREROUTING) + +--- + +위의 NAT 관련 글을 읽다 보면 자연스럽게 이런 의문이 들 수 있습니다. + +> “그러면 **실제로 주소를 바꾸는 건 누가 하는 거지?** + IP/포트를 바꾸려면, 이 트래픽이 **어디에서 와서 어디로 가는지**에 대한 정보가 있어야 할 텐데, 이런 정보는 어디에 저장되어 있을까?” +> + +# [6-12-8] conntrack + +리눅스 커널에서는 이 정보를 **conntrack(커넥션 트래킹)** 이라는 모듈을 통해 관리합니다. + +조금 더 정확히 말하면, 리눅스에는 **Netfilter**라는 패킷 처리 프레임워크가 있고, 이 Netfilter가 커널 네트워크 경로에 여러 개의 **“훅(hook)” 지점**을 미리 심어 두고 있습니다. + +### Netfilter 훅이란 무엇인가? + +훅은 쉽게 말해, **패킷이 커널 네트워크 스택을 지나가는 길목에 미리 설치해 둔 “검문소”** 같은 것입니다. + +패킷은 다음과 같은 순서로 커널을 통과합니다. + +```text +[ NIC 수신 ] + │ + ▼ +(옵션) XDP/eBPF + │ + ▼ +[ IP 계층 진입 ] + │ + ▼ + ┌───────────────────────────────┐ + │ Netfilter 훅 지점들 │ + │ (여기에 conntrack, NAT 등이 │ + │ 순서대로 매달려 있다) │ + └───────────────────────────────┘ + │ + ▼ +[ 라우팅 / 로컬 소켓 전달 ] + │ + ▼ +[ 응답 패킷 생성 후 다시 Netfilter 훅들 ] + │ + ▼ +[ NIC 송신 ] +``` + +Netfilter는 이 안에 대표적으로 다섯 개의 훅 지점을 제공합니다. + +- `PRE_ROUTING` : 패킷이 IP 계층에 들어오자마자 +- `LOCAL_IN` : 이 노드가 목적지인 패킷이 로컬 소켓으로 올라가기 직전 +- `FORWARD` : 이 노드를 경유해 다른 곳으로 포워딩될 때 +- `LOCAL_OUT` : 로컬 프로세스에서 나가는 패킷이 IP 계층을 통과할 때 +- `POST_ROUTING` : 라우팅이 끝나고 실제로 NIC로 나가기 직전 + +각 훅 지점에는 여러 모듈(conntrack, NAT, iptables 등)이 **우선순위에 따라 줄지어 등록**됩니다. + +예를 들어 `LOCAL_OUT` 훅은 개념적으로 다음과 같은 구조를 가집니다. + +```text +NF_INET_LOCAL_OUT 훅 + ├─ conntrack_in() ← conntrack 모듈 (먼저 실행) + ├─ nf_nat_inet_fn() ← NAT 모듈 (그 다음 실행) + └─ iptables(filter 등) ← 방화벽/정책 +``` + +즉, 커널은 “이 지점에서 등록된 핸들러들을 순서대로 호출해 줌으로써” conntrack, NAT, iptables가 개입할 수 있는 타이밍을 제공하는 역할을 합니다. + +### conntrack: “연결 정보를 만들고 skb에 붙이는” 층 + +이제 NAT를 이해하기 위해 conntrack가 어떤 일을 하는지 조금 더 구체적으로 보겠습니다. + +리눅스 커널에서 패킷 하나는 `struct sk_buff`(줄여서 `skb`)라는 구조체로 표현됩니다. + +- `skb`는 실제 패킷 데이터(헤더+payload)뿐 아니라, 이 패킷과 관련된 **메타데이터**를 함께 들고 다니는 컨테이너입니다. 그 메타데이터 중 하나가 바로 “이 패킷이 어떤 conntrack 엔트리에 속하는지”를 가리키는 포인터입니다. + +`PRE_ROUTING`이나 `LOCAL_OUT` 같은 훅 지점에서 **가장 먼저 호출되는 것이 `nf_conntrack_in()`**입니다. 이 함수는 개념적으로 다음과 같은 일을 합니다. + +1. 패킷 헤더에서 5‑tuple(출발지 IP/포트, 목적지 IP/포트, L4 프로토콜)을 추출합니다. +2. 이 5‑tuple을 키로 해서 **conntrack 해시 테이블**을 조회합니다. +3. 이미 같은 튜플을 가진 엔트리가 있으면 그 엔트리(`struct nf_conn *ct`)를 가져오고, 없다면 새로 할당해서 초기 상태/타임아웃 등을 설정한 뒤 테이블에 등록합니다. +4. 이렇게 얻은 `ct` 포인터와 상태 정보(NEW, ESTABLISHED, RELATED 등)를 **`skb`의 메타데이터 필드에 붙여 둡니다.** + +개념적으로는 다음과 같습니다. + +```text +[ 패킷(skb) ] + │ + └─ nf_conntrack_in() + ├─ 튜플 추출 + ├─ conntrack 테이블에서 ct 찾기 또는 새로 생성 + └─ skb에 ct 포인터와 상태를 저장 + (nfct / nfctinfo 필드에 연결) +``` + +이렇게 “붙여 둔” 덕분에, 이후에 다른 모듈(NAT 등)이 이 패킷을 처리할 때는 다시 튜플을 파싱해서 연결을 찾을 필요가 없고, `nf_ct_get(skb, &ctinfo)` 같은 헬퍼 함수로 **이미 conntrack가 만들어 놓은 `ct` 엔트리와 상태 정보를 그대로 가져다 쓸 수 있게 됩니다.** + +### NAT: conntrack 정보 + 규칙을 바탕으로 실제 주소를 바꾸는 층 + +conntrack가 먼저 일을 끝내고 나면, 같은 훅에서 **그 다음 순서로 NAT 모듈**이 호출됩니다 (`nf_nat_inet_fn()` 등). + +NAT의 동작 흐름은 다음과 같습니다. + +1. `nf_ct_get(skb, &ctinfo)`를 호출해서, conntrack가 `skb`에 붙여 둔 `ct` 엔트리와 상태(NEW/ESTABLISHED/등)를 가져옵니다. +2. iptables NAT 규칙(SNAT, DNAT, Masquerade 설정)을 보고, + + “이 connection에 대해 어떤 형태의 NAT를 적용해야 하는지”를 결정합니다. + +3. 이 결정 내용을 `ct`와 NAT 전용 상태에 기록해 둡니다. + + (예: 이 플로우는 출발지 IP/포트를 어떤 값으로 바꿔야 하는지) + +4. 마지막으로 현재 처리 중인 `skb`의 IP/포트 헤더를 위에서 정한 매핑에 따라 **실제로 변경**합니다. + +이 과정을 텍스트로 시각화하면 다음과 같은 파이프라인이 됩니다. + +```text +[ LOCAL_OUT 훅에 도달한 패킷 (skb) ] + │ + ├─ conntrack_in() + │ ├─ 튜플 추출 (src/dst IP, src/dst port, proto) + │ ├─ conntrack 테이블에서 ct 엔트리 조회/생성 + │ └─ skb.nfct / skb.nfctinfo에 ct 포인터와 상태 기록 + │ + ├─ nf_nat_inet_fn() + │ ├─ nf_ct_get(skb, &ctinfo)로 ct 가져오기 + │ ├─ NAT 규칙(SNAT/DNAT/masq)을 보고, + │ │ 이 connection에 대한 NAT 매핑 결정 + │ ├─ 그 매핑을 ct/NAT 상태에 저장 + │ └─ skb의 IP/포트를 매핑에 맞게 실제로 변경 + │ + └─ iptables(filter 등)로 최종 ACCEPT/DROP/LOG 등 판정 +``` + +이후 같은 connection에 속한 패킷이 다시 들어오면: + +- conntrack는 같은 튜플을 기반으로 동일한 `ct` 엔트리를 찾아 `skb`에 붙여 주고, +- NAT는 그 `ct`에 이미 저장된 NAT 매핑을 그대로 재사용함으로써, + + **한 connection 동안 주소 변환이 항상 일관되게 유지**되도록 합니다. + + +### 결론 + +이제 처음 질문으로 돌아가서, 이렇게 정리할 수 있습니다. + +- 주소를 바꾸기 위해서는 “이 패킷이 어떤 연결에 속하는지”와 “그 연결에 대해 어떤 NAT 정책/매핑이 적용돼야 하는지”라는 정보가 필요합니다. +- 리눅스에서는 이 정보를 **conntrack가 튜플과 `ct` 엔트리 형태로 관리**하고, 패킷 단위로는 이 `ct` 포인터를 `skb`에 붙여서 넘깁니다. +- Netfilter 훅은 conntrack와 NAT가 이런 작업을 수행할 수 있는 타이밍을 제공하는 “검문소” 역할을 합니다. +- NAT는 바로 이 `ct` 정보와 iptables NAT 설정을 기반으로, 각 패킷의 IP/포트를 실제로 바꾸는 마지막 단계를 담당합니다. + +따라서, 리눅스 커널에서 NAT의 동작 원리를 한 줄로 요약하면 다음과 같습니다. + +> **“Netfilter 훅 위에서 conntrack가 먼저 ‘연결 정보를 만들고 skb에 붙여 두고’, NAT가 그 정보를 기반으로 일관된 SNAT/DNAT를 수행한다.”** + +# 참조 + +[RFC 3022 - 전통적인 IPv4 NAT 정의 문서](https://datatracker.ietf.org/doc/html/rfc3022) + +[Netfilter NAT HOWTO - 리눅스 NAT 공식 가이드](https://www.netfilter.org/documentation/HOWTO/NAT-HOWTO.html) + +[Linux conntrack 설계와 구현 분석 글](https://arthurchiao.art/blog/conntrack-design-and-implementation/) \ No newline at end of file diff --git a/lectures/ch6_lec.qmd b/lectures/ch6_lec.qmd new file mode 100644 index 0000000..9ac3b48 --- /dev/null +++ b/lectures/ch6_lec.qmd @@ -0,0 +1,13 @@ +--- +title: "6장. Neutron" +--- + +# Neutron 개요 + +Neutron은 오픈스택의 네트워킹 서비스입니다. 이 장에서는 Neutron의 핵심 개념들을 다룹니다. + +## 하위 목차 + +- [6-1 Neutron 에이전트 종류 정리](ch6/neutron_agents.qmd) +- [6-12 SNAT/DNAT 개념](ch6/snat_dnat.qmd) +- [6-15 OVS/VXLAN 가상 네트워크 만들기](ch6/ovs_vxlan_vpn.qmd) diff --git a/lectures/index.qmd b/lectures/index.qmd index ab4d52c..e0e9993 100644 --- a/lectures/index.qmd +++ b/lectures/index.qmd @@ -15,4 +15,7 @@ title: "오픈스택 강의 자료" - [4장. Nova]() - [4-1장. nova의 서비스 종류]() - [5장. Glance]() -- [6장. neutron]() +- [6장. Neutron](ch6_lec.qmd) + - [SNAT/DNAT 개념](ch6/snat_dnat.qmd) + - [Neutron Agent 종류 정리](ch6/neutron_agents.qmd) + - [OVS/VXLAN 가상 네트워크 만들기](ch6/ovs_vxlan_vpn.qmd) \ No newline at end of file diff --git a/tools/imports/README.md b/tools/imports/README.md new file mode 100644 index 0000000..68ffd1b --- /dev/null +++ b/tools/imports/README.md @@ -0,0 +1,30 @@ +# tools/imports/ + +노션에서 내보낸 ZIP 파일을 넣는 폴더. **lectures/ 와 동일한 챕터 구조**를 사용합니다. + +## 폴더 구조 + +``` +tools/imports/ +├── ch1/ ← 1장용 ZIP +├── ch2/ ← 2장용 ZIP +├── ch3/ +├── ch4/ +├── ch5/ +└── ch6/ ← 6장 Neutron (SNAT/DNAT 등) + ├── SNAT, DNAT 개념.zip + └── processed/ ← 변환이 끝난 ZIP이 자동으로 이동되는 폴더 +``` + +## 사용법 + +1. 노션에서 **내보내기** → **Markdown & CSV** 선택 +2. 다운로드한 ZIP을 해당 챕터 폴더에 복사 (예: `tools/imports/ch6/`) +3. 스크립트 실행: + +```bash +cd tools/scripts +python3 import_notion_zip.py "SNAT, DNAT 개념.zip" ch6 snat_dnat +``` + +파일명만 입력하면 `imports/{챕터}/` 에서 자동으로 찾습니다. diff --git a/tools/scripts/README.md b/tools/scripts/README.md new file mode 100644 index 0000000..5ed68f3 --- /dev/null +++ b/tools/scripts/README.md @@ -0,0 +1,156 @@ +# 노션 글 가져오기 + +노션에 써둔 글을 이 사이트에 넣을 때 사용하는 도구입니다. + +- 이 파일(`scripts/README.md`)은 **사용법 설명만** 수정합니다. +- 실제 동작은 `import_notion_zip.py`가 담당하므로, 코드를 바꾸기 전에는 **이 README의 I/O 요약을 먼저** 확인하세요. + +--- + +## 3단계로 끝내기 + +### 1단계: 노션에서 내보내기 + +1. 노션 페이지 오른쪽 상단 **`•••`** 클릭 +2. **내보내기** → **Markdown & CSV** 선택 +3. **내보내기** 클릭 → ZIP 다운로드 + +### 2단계: ZIP 파일 넣기 + +다운로드한 ZIP을 **해당 챕터 폴더**에 복사합니다. + +``` +tools/imports/ +├── ch1/ ← 1장용 +├── ch2/ ← 2장용 +├── ch3/ +├── ch4/ +├── ch5/ +└── ch6/ ← 6장 Neutron (네트워킹 개념 등) +``` + +예: 6장에 넣을 글이면 `tools/imports/ch6/` 폴더에 ZIP 복사 + +### 3단계: 스크립트 실행 + +```bash +cd openstack-book/tools/scripts +python3 import_notion_zip.py "ZIP파일명.zip" ch6 글슬러그 --title "사이드바에 표시할 제목" +``` + +**실제 예시**: + +```bash +# SNAT/DNAT 글 +python3 import_notion_zip.py "SNAT, DNAT 개념.zip" ch6 snat_dnat --title "SNAT/DNAT 개념" +``` + +끝이에요. 그러면 `lectures/ch6/` 에 문서가 생성되고, 사이드바/목차에도 자동으로 들어갑니다. + +--- + +## 인자 설명 + +| 입력 | 필수 | 설명 | +|------|:----:|------| +| ZIP파일명 | O | `tools/imports/ch6/` 에 넣은 파일 이름 그대로 (예: `"SNAT, DNAT 개념.zip"`) | +| 챕터 | O | ch1, ch2, ch3, ch4, ch5, ch6 | +| 글슬러그 | △ | 파일명 (생략 시 자동). 예: `snat_dnat` | +| --title | △ | 사이드바에 보일 제목 | + +--- + +## 스크립트 I/O 요약 (에이전트용) + +- **입력** + - ZIP 파일: `tools/imports/{chapter}/*.zip` + - 명령: + `python3 import_notion_zip.py [slug] [--title]` +- **출력** + - 문서: `lectures/{chapter}/{slug}.qmd` + - 이미지: `lectures/{chapter}/images/{slug}/*` + - 네비게이션 업데이트: `_quarto.yml`, `lectures/index.qmd`에 항목 추가 + - ZIP 정리: 사용이 끝난 ZIP은 `tools/imports/{chapter}/processed/` 로 이동 + +- **ZIP 처리 규칙 (중요)** + - 처리 대상 ZIP은 **항상** `tools/imports/{chapter}/`에서만 찾고, `tools/imports/{chapter}/processed/`는 **검색/실행 대상에 포함하지 않는다.** + - `tools/imports/{chapter}/processed/` 안의 ZIP 파일은 `import_notion_zip.py`로 **이미 반영이 끝난 파일**이므로, 다시 변환 대상으로 사용하지 않는다. + +- **에이전트 후속 작업 체크리스트** + - 새로 생성된 문서/이미지 경로를 사용자에게 요약해서 알려준다. + - 해당 챕터에 `lectures/ch{N}_lec.qmd` 같은 **챕터 소개 문서**가 있는 경우: + - 그 문서에 `## 하위 목차` 섹션이 있으면, 방금 생성한 글을 하위 목차에 **추가할지 사용자에게 묻거나, 사용자가 원하면 직접 추가**한다. + - 예: `ch6`인 경우 `lectures/ch6_lec.qmd`의 하위 목차에 `lectures/ch6/{slug}.qmd` 링크를 맞춰 준다. + - ch6 강의 문서를 처음 다룰 때, frontmatter에 `code:` 필드가 없으면 **가장 먼저** 사용자에게 어떤 섹션 코드로 묶을지 질문한다. + - 예: `"이 문서를 ch6 몇 번 코드로 묶을까요? (예: 6-1, 6-12, 6-15 중 하나)"` + - 사용자가 선택/응답한 코드를 `code: "6-12"` 처럼 frontmatter에 추가한 뒤, 이후 그림/표 번호 계산에 사용한다. + - ch6에서는 보통 이 `code` 값을 **제목(title) 앞에 붙여서** 사용한다. + - 예: `title: "6-12 SNAT, DNAT 개념 정리"` 처럼 `6-12`를 제목에 포함. + - 강의 본문(qmd)에는 필요하다면 마지막에 `# 참조` 섹션을 추가하고, 참조 링크를 다음 형식으로 정리해 둔다. + - `# 참조` 아래에 한 줄씩 + `[# 이 링크가 무엇을 설명하는지 한글로 요약](https://example.com/doc)` + 같은 형태로 **설명 + 하이퍼링크**를 쓴다. + - 예: `neutron_agents.qmd`에서는 + `[Red Hat OpenStack Platform Neutron 설정 문서](https://docs.redhat.com/ko/documentation/red_hat_openstack_platform/16.2/html/configuration_reference/neutron_2)` + 처럼, 공식 문서가 무엇을 다루는지 한글로 설명을 붙여 준다. + - 강의 본문 헤딩(`#`, `##`, `###`)에는 가능하면 **섹션 코드/번호**를 붙이되, ch6에서는 다음과 같이 정리한다. + - 기본 원칙: + - 섹션 코드는 **frontmatter의 `code:` 필드**와 **제목(title)** 에 우선 반영한다. + - 예: `code: "6-12"`, `title: "6-12 SNAT, DNAT 개념 정리"`. + - 본문 첫 `##` 헤딩에는 같은 번호를 **중복해서 다시 붙이지 않는다.** + - 즉, `## NAT가 왜 생겼는지`처럼 내용 위주의 헤딩만 둔다. + - 일반적인 장/절 구조(코드 필드가 없는 다른 장)에서는 기존처럼 `## 2.2.5 VM NIC 3개 구성 및 정적 IP 설정` 형태를 사용할 수 있다. + - 번호가 누락된 경우, 에이전트는 상위 헤딩 구조와 순서를 기준으로 번호를 **추론**만 하고, 실제 헤딩 텍스트 수정은 PR/리뷰에서 사람과 함께 확정하는 것을 권장한다. + - 그림/표 번호는 이 헤딩/코드(예: `6-12`)를 기준으로 `그림 6-12-1`, `표 6-12-1` 처럼 계산한다. + - 그림을 추가/정리할 때는 다음 형태를 기본으로 사용한다. + - `![그림 6-12-1. SNAT/DNAT 예시 흐름](images/ch6/snat_dnat_1.png){width="55%" fig-align="left"}` + → **번호 + 한 줄 설명 + 파일명 + 왼쪽 정렬**이 한 번에 정리되도록 돕는다. + - 코드 블록(```` ``` ````) 언어 태그를 실제 내용과 맞춰 정리한다. + - 사용자가 `@파일경로 (21-26)`처럼 특정 범위를 가리킬 때, 그 범위가 **명령어**인지 **설정 파일**인지 보고 언어를 결정한다. + - 리눅스/셸 명령어(`sudo apt ...`, `sudo ovs-vsctl ...`, `openstack server ...` 등)인 경우 → ` ```bash ` 로 맞춘다. + - ini 형식 설정(`foo = bar`가 연속)인 경우 → ` ```ini ` 또는 맥락에 맞는 형식으로 태그한다. + - YAML/JSON 등은 해당 포맷에 맞게 ` ```yaml `, ` ```json `을 사용한다. + - Notion에서 가져온 코드 블록이 언어 없이 내려왔거나 잘못 태그된 경우, **실제 내용에 맞게 언어를 고쳐 주는 것**이 에이전트의 기본 후처리 작업이다. + - 예: `@lectures/ch6/ovs_vxlan_vpn.qmd (21-26)` 구간은 OVS 설치 명령이므로, 아래처럼 정리한다. + - 잘못된 예: + - ` ``` ` (언어 없음) + - 정리 후: + - ` ```bash` + `sudo apt update` + `sudo apt install -y openvswitch-switch` + `sudo systemctl status openvswitch-switch` + ` ```` + - 코드 블록인데 실제로는 **그림/다이어그램을 글로 그려 놓은 경우**도 올바르게 정리한다. + - 예: 패킷 흐름, 토폴로지, 구성 요소 관계 등을 `|-`, `+`, `->` 같은 문자로 그려 놓은 ASCII 다이어그램. + - 이런 블록은 실행 가능한 코드가 아니므로, 다음 중 하나로 정리한다. + - **설명 위주라면 일반 본문/리스트/표로 풀어서 재작성**하고, 가능하면 별도 그림 파일로 만드는 것을 권장한다. + - 그대로 둘 필요가 있을 때는 언어 태그를 `text` 또는 비워 두어, ` ```text` 처럼 “코드가 아닌 텍스트 블록”임을 드러낸다. + - 셸/프로그래밍 언어로 잘못 태그된 ASCII 다이어그램은, 반드시 `text`/무태그로 바꾸거나 일반 문단으로 풀어서 정리한다. + +--- + +## 챕터가 뭔가요? + +| 챕터 | 해당 장 | +|------|---------| +| ch1 | 1장. 오픈스택 개요 | +| ch2 | 2장. 오픈스택 설치 해보기 | +| ch3 | 3장. Keystone | +| ch4 | 4장. Nova | +| ch5 | 5장. Glance | +| ch6 | 6장. Neutron (네트워킹 개념) | + +--- + +## 결과물 + +``` +lectures/ch6/ +├── images/ +│ └── snat_dnat/ ← 이미지들 +│ ├── image.png +│ └── image 1.png +└── snat_dnat.qmd ← 변환된 문서 +``` + +이미지 경로, 사이트 네비게이션, 목차는 모두 자동으로 처리됩니다. diff --git a/tools/scripts/import_notion.sh b/tools/scripts/import_notion.sh new file mode 100644 index 0000000..8306b65 --- /dev/null +++ b/tools/scripts/import_notion.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# 노션 ZIP → QMD 변환 래퍼 +# 사용법: ./import_notion.sh <챕터> [글슬러그] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" +python3 import_notion_zip.py "$@" diff --git a/tools/scripts/import_notion_zip.py b/tools/scripts/import_notion_zip.py new file mode 100644 index 0000000..30cd82c --- /dev/null +++ b/tools/scripts/import_notion_zip.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +노션 ZIP 내보내기 → Quarto QMD 변환 스크립트 + +사용법: + python import_notion_zip.py <챕터> [글슬러그] + +ZIP 위치: tools/imports/ 가 lectures/ 와 동일한 챕터 구조 + tools/imports/ch6/SNAT, DNAT 개념.zip → ch6 에 넣을 때: "SNAT, DNAT 개념.zip" ch6 + - 파일명만 입력 시 tools/imports/{챕터}/ 에서 찾음 + - 절대/상대 경로로 직접 지정도 가능 + +챕터: ch1~ch6 +예시: + python import_notion_zip.py "SNAT, DNAT 개념.zip" ch6 snat_dnat +""" + +import argparse +import os +import re +import shutil +import zipfile +from pathlib import Path +from urllib.parse import unquote + + +IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} + +# 프로젝트 루트 기준 경로 (스크립트는 tools/scripts/ 에 있음) +PROJECT_ROOT = Path(__file__).resolve().parents[2] +IMPORTS_DIR = PROJECT_ROOT / 'tools' / 'imports' # ZIP 파일 기본 위치 + + +def find_images_for_md(md_path: Path, md_content: str, article_slug: str) -> tuple[list[Path], dict[str, str]]: + """md에서 참조하는 이미지 찾기. 반환: (파일 목록, {원본경로: 새경로} 매핑)""" + root = md_path.parent + found_files = [] + path_mapping = {} + + for alt, src in re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', md_content): + src = src.strip() + if not src or src.startswith(('http://', 'https://', 'data:')): + continue + src_decoded = unquote(src) + src_path = root / src_decoded.replace('/', os.sep) + if src_path.exists(): + found_files.append(src_path) + new_rel = f"images/{article_slug}/{src_path.name}" + path_mapping[src] = new_rel + path_mapping[src_decoded] = new_rel + + page_base = re.sub(r'\s+[a-f0-9]{32}$', '', md_path.stem, flags=re.I).strip() + for folder in root.iterdir(): + if folder.is_dir() and len(page_base) > 3 and (page_base[:20] in folder.name or folder.name in page_base): + for img in folder.iterdir(): + if img.suffix.lower() in IMAGE_EXTENSIONS: + found_files.append(img) + rel = f"{folder.name}/{img.name}" + path_mapping[rel] = f"images/{article_slug}/{img.name}" + path_mapping[f"{folder.name}/{img.name}"] = f"images/{article_slug}/{img.name}" + + return list(set(found_files)), path_mapping + + +def slugify(name: str) -> str: + name = re.sub(r'\s+[a-f0-9]{32}$', '', name, flags=re.I) + slug = re.sub(r'[^\w\s가-힣-]', '', name) + slug = re.sub(r'[\s,]+', '_', slug).strip('_').lower() + return slug or 'article' + + +# 챕터 → index.qmd N장 패턴 매핑 +CHAPTER_PATTERNS = { + 'ch1': r'1장', + 'ch2': r'2장', + 'ch3': r'3장', + 'ch4': r'4장', + 'ch5': r'5장', + 'ch6': r'6장', +} + + +def auto_link_bare_urls(text: str) -> str: + """본문에서 단독으로 있는 URL 라인을 [URL](URL) 형태의 하이퍼링크로 변환.""" + lines = text.split('\n') + new_lines: list[str] = [] + for line in lines: + stripped = line.strip() + # 이미 마크다운 링크/이미지인 경우는 건너뜀 + if stripped.startswith('[') or stripped.startswith('!['): + new_lines.append(line) + continue + # 한 줄 전체가 순수 URL이면 링크로 감쌈 + if re.match(r'^https?://\S+$', stripped): + url = stripped + new_lines.append(line.replace(stripped, f'[{url}]({url})')) + else: + new_lines.append(line) + return '\n'.join(new_lines) + + +def _update_index_qmd(lectures_dir: Path, chapter: str, article_slug: str, title: str): + """index.qmd에 지정 챕터 하위로 링크 추가 (top-level 아님)""" + index_path = lectures_dir / 'index.qmd' + if not index_path.exists() or f'{chapter}/{article_slug}.qmd' in index_path.read_text(): + return + + pattern = CHAPTER_PATTERNS.get(chapter, chapter.replace('ch', '') + '장') + lines = index_path.read_text(encoding='utf-8').split('\n') + new_line = f' - [{title}]({chapter}/{article_slug}.qmd)' + + insert_idx = None + base_indent = None + for i, line in enumerate(lines): + if pattern in line and re.search(r'\[.*?장\.', line): + base_indent = len(line) - len(line.lstrip()) + insert_idx = i + 1 + while insert_idx < len(lines): + next_line = lines[insert_idx] + next_indent = len(next_line) - len(next_line.lstrip()) if next_line.strip() else 0 + if next_line.strip() and next_indent <= base_indent: + break + insert_idx += 1 + break + + if insert_idx is not None: + lines.insert(insert_idx, new_line) + index_path.write_text('\n'.join(lines), encoding='utf-8') + print("등록됨: lectures/index.qmd") + + +def _update_quarto_yml(base_dir: Path, chapter: str, article_slug: str, title: str): + """_quarto.yml에 지정 챕터 섹션 하위로 항목 추가""" + quarto_yml = base_dir / '_quarto.yml' + if not quarto_yml.exists(): + return + yml_text = quarto_yml.read_text(encoding='utf-8') + if f'lectures/{chapter}/{article_slug}.qmd' in yml_text: + return + + new_entry = f' - text: "{title}"\n file: lectures/{chapter}/{article_slug}.qmd\n' + pattern = CHAPTER_PATTERNS.get(chapter, chapter.replace('ch', '') + '장') + + # 6장 등 section이 있는 경우: 해당 section의 contents 끝에 추가 + section_re = re.search( + rf'(- section: ".*{pattern}.*"\s*\n\s*contents:\s*\n)(.*?)(?=\n\s*format:|\n\s{{8}}-(?:\s+section:|\s+text:))', + yml_text, re.DOTALL + ) + if section_re: + prefix, section_body = section_re.group(1), section_re.group(2) + yml_text = yml_text.replace( + section_re.group(0), + prefix + section_body.rstrip() + '\n' + new_entry + ) + else: + yml_text = yml_text.replace('\nformat:', '\n' + new_entry + '\nformat:') + + quarto_yml.write_text(yml_text, encoding='utf-8') + print("등록됨: _quarto.yml") + + +def add_yaml_frontmatter(content: str, title: str, description: str = '') -> str: + lines = content.strip().split('\n') + start = 0 + for i, line in enumerate(lines): + # 노션에서 내려오는 메타 정보(범주, 시리즈, 작성시간, 참여자 등)는 본문에서 제외 + if re.match(r'^(범주|시리즈|작성시간|참여자)\b', line.strip()): + continue + if line.strip().startswith('# ') or line.strip(): + start = i + break + + # 본문에서 메타 정보 라인 제거 + body_lines = [ + l for l in lines[start:] + if not re.match(r'^(범주|시리즈|작성시간|참여자)\b', l.strip()) + ] + + # 첫 번째 H1 제목("# 제목")을 찾아 frontmatter 제목으로 쓰고, + # 본문에서는 중복되지 않도록 제거 + doc_title = title + heading_idx = None + for idx, l in enumerate(body_lines): + m = re.match(r'^#\s+(.+)$', l.strip()) + if m: + doc_title = m.group(1).strip() + heading_idx = idx + break + + if heading_idx is not None: + filtered = [] + for idx, l in enumerate(body_lines): + # H1 제목 라인 제거 + if idx == heading_idx: + continue + # 제목 바로 다음 줄이 공백이면 같이 제거해 중복 여백 방지 + if idx == heading_idx + 1 and not l.strip(): + continue + filtered.append(l) + body_lines = filtered + + body = '\n'.join(body_lines).strip() + + return f'''--- +title: "{doc_title}" +description: "{description or doc_title}" +--- + +''' + body + + +def main(): + parser = argparse.ArgumentParser(description='노션 ZIP → Quarto QMD 변환') + parser.add_argument('zip_path', help='노션 내보내기 ZIP 파일 경로') + parser.add_argument('chapter', help='챕터 폴더명 (예: ch2, ch3, networking)') + parser.add_argument('slug', nargs='?', help='글 슬러그 (생략시 md 파일명에서 추출)') + parser.add_argument('--title', help='사이드바에 표시할 제목') + parser.add_argument('--no-register', action='store_true', help='_quarto.yml 자동 등록 안 함') + parser.add_argument('--dry-run', action='store_true', help='실제 파일 생성 없이 확인만') + args = parser.parse_args() + + base_dir = PROJECT_ROOT + lectures_dir = base_dir / 'lectures' + chapter = args.chapter.strip().lower().replace(' ', '_') + + # ZIP 경로: 파일명만이면 tools/imports/{챕터}/ 에서 찾음 (lectures 구조와 동일) + zip_arg = args.zip_path.strip() + if '/' not in zip_arg and '\\' not in zip_arg: + zip_path = IMPORTS_DIR / chapter / zip_arg + elif zip_arg.startswith('tools/imports/') or zip_arg.startswith('tools\\imports\\'): + zip_path = PROJECT_ROOT / zip_arg.replace('\\', '/') + else: + zip_path = Path(zip_arg).resolve() + + if not zip_path.exists(): + imports_chapter = IMPORTS_DIR / chapter + if '/' not in zip_arg and '\\' not in zip_arg and not imports_chapter.exists(): + imports_chapter.mkdir(parents=True) + print(f"tools/imports/{chapter}/ 폴더를 생성했습니다. ZIP 파일을 넣어주세요: {imports_chapter}") + print(f"오류: ZIP 파일을 찾을 수 없습니다: {zip_path}") + return 1 + chapter_dir = lectures_dir / chapter + chapter_dir.mkdir(parents=True, exist_ok=True) + (IMPORTS_DIR / chapter).mkdir(parents=True, exist_ok=True) + chapter_dir.mkdir(parents=True, exist_ok=True) + + extract_root = base_dir / '.tmp_extract' / zip_path.stem + extract_root.mkdir(parents=True, exist_ok=True) + try: + with zipfile.ZipFile(zip_path, 'r') as zf: + for name in zf.namelist(): + try: + zf.extract(name, extract_root) + except Exception: + pass + for nested in extract_root.rglob('*.zip'): + with zipfile.ZipFile(nested, 'r') as zf: + for name in zf.namelist(): + try: + zf.extract(name, nested.parent) + except Exception: + pass + nested.unlink() + + md_files = list(extract_root.rglob('*.md')) + if not md_files: + print("오류: ZIP 내에 .md 파일을 찾을 수 없습니다.") + return 1 + + md_path = md_files[0] + slug = args.slug or slugify(md_path.stem) + article_slug = slug + + md_content = md_path.read_text(encoding='utf-8', errors='replace') + found_images, path_mapping = find_images_for_md(md_path, md_content, article_slug) + + images_dir = chapter_dir / 'images' / article_slug + if not args.dry_run: + images_dir.mkdir(parents=True, exist_ok=True) + for img_path in found_images: + shutil.copy2(img_path, images_dir / img_path.name) + + new_content = md_content + for old_path in sorted(path_mapping.keys(), key=len, reverse=True): + new_rel = path_mapping[old_path] + new_content = new_content.replace(f']({old_path})', f']({new_rel})') + new_content = new_content.replace(f'="{old_path}"', f'="{new_rel}"') + + title_from_doc = re.search(r'^#\s+(.+)$', md_content, re.M) + doc_title = title_from_doc.group(1).strip() if title_from_doc else md_path.stem + qmd_content = add_yaml_frontmatter(new_content, doc_title) + qmd_content = auto_link_bare_urls(qmd_content) + qmd_path = chapter_dir / f'{article_slug}.qmd' + + if args.dry_run: + print(f"[DRY-RUN] 생성될 파일:") + print(f" - {qmd_path.relative_to(base_dir)}") + print(f" - {images_dir.relative_to(base_dir)}/ ({len(found_images)}개 이미지)") + return 0 + + qmd_path.write_text(qmd_content, encoding='utf-8') + print(f"생성됨: {qmd_path.relative_to(base_dir)}") + print(f"이미지: {images_dir.relative_to(base_dir)}/ ({len(found_images)}개)") + + if not args.no_register: + _update_quarto_yml(base_dir, chapter, article_slug, args.title or doc_title) + _update_index_qmd(lectures_dir, chapter, article_slug, args.title or doc_title) + + # 성공적으로 반영된 ZIP은 processed/ 폴더로 이동해 상태를 구분 + processed_dir = IMPORTS_DIR / chapter / "processed" + processed_dir.mkdir(parents=True, exist_ok=True) + try: + new_zip_path = processed_dir / zip_path.name + if new_zip_path.exists(): + new_zip_path.unlink() + shutil.move(str(zip_path), str(new_zip_path)) + print(f"ZIP 이동: {zip_path} -> {new_zip_path}") + except Exception as e: + print(f"경고: ZIP 이동 중 오류가 발생했지만 변환은 완료되었습니다: {e}") + + finally: + if extract_root.exists(): + shutil.rmtree(extract_root, ignore_errors=True) + + return 0 + + +if __name__ == '__main__': + exit(main())