본문 바로가기
<개인공부> - IT/[Network&Security]

Nornir를 활용한 네트워크 자동화 (1) - Nornir 소개와 환경 구성

by Aggies '19 2025. 2. 16.
반응형

  최근 회사에서 Datacenter Isolation을 진행하는 프로젝트를 진행해보았다. Network 장비를 접속하기 위해서 Netmiko는 많이 사용해봤지만 이번 프로젝트를 통해서 Nornir를 사용해봤다. 추후 Nornir를 이용할 것이라 생각되기에 이번 포스트를 통해서 내가 찾아봤던 내용들을 기록하여 추후에 참고가 되는 자료를 만들어보고자 한다. 첫 포스트에서는 Python 기반의 네트워크 자동화 프레임워크인 Nornir에 대해 소개하고, 기본적인 환경 구성 방법에 대해 알아보자.

1. Nornir란?

Nornir는 Python으로 작성된 네트워크 자동화 프레임워크이다. 기존의 Ansible과 같은 도구들과는 달리, Python 네이티브 환경에서 동작하며 이로 인해 Python 언어를 알고있다면 큰 learning curve가 필요하지 않다.

1.1 Nornir의 주요 특징

  1. Python 네이티브 환경
    1. 별도의 DSL(Domain Specific Language) 학습이 필요 없음
    2. Python의 모든 기능과 라이브러리를 자유롭게 활용 가능
    3. 디버깅이 용이하고 IDE 지원이 뛰어남
  2. 뛰어난 성능
    1. Ansible 대비 3-4배 빠른 실행 속도
    2. 멀티스레딩 지원으로 병렬 처리 최적화
    3. 메모리 사용량 최적화
  3. 높은 유연성
    1. 커스텀 태스크 작성이 자유로움
    2. 다양한 인벤토리 소스 지원 (YAML, JSON, Python 객체 등)
    3. 플러그인 시스템을 통한 확장성

1.2 Ansible과의 차이점

특징 Nornir Ansible
언어 Python 네이티브 YAML + Python
실행 속도 매우 빠름 상대적으로 느림
학습 곡선 Python 개발자에게 쉬움 YAML 문법 학습 필요
디버깅 Python 디버거 사용 가능 제한적인 디버깅
커뮤니티 작지만 성장 중 매우 큰 커뮤니티

2. 환경 구성

2.1 Python 가상환경 설정

먼저 Python 가상환경을 생성

# 가상환경 생성
python -m venv nornir-env

# 가상환경 활성화 (Linux/Mac)
source nornir-env/bin/activate

# 가상환경 활성화 (Windows)
.\nornir-env\Scripts\activate

2.2 Nornir 설치

필요한 패키지들을 설치

pip install nornir
pip install nornir_utils
pip install nornir_netmiko
pip install nornir_napalm

2.3 프로젝트 구조 설정

기본적인 프로젝트 구조를 다음과 같이 설정

nornir-project/
├── config.yaml
├── inventory/
│   ├── hosts.yaml
│   ├── groups.yaml
│   └── defaults.yaml
├── tasks/
│   └── custom_tasks.py
└── scripts/
    └── main.py

2.4 기본 설정 파일 구성

config.yaml 파일을 다음과 같이 구성합니다:

---
inventory:
    plugin: SimpleInventory
    options:
        host_file: "inventory/hosts.yaml"
        group_file: "inventory/groups.yaml"
        defaults_file: "inventory/defaults.yaml"

runner:
    plugin: threaded
    options:
        num_workers: 16

logging:
    enabled: true
    level: DEBUG
    file: "nornir.log"

3. 기본 개념 이해

3.1 Inventory 관리

Nornir의 인벤토리는 다음 세 가지 파일로 구성된다. 물론, hosts.yaml과 groups.yaml만 있어도 프로그램을 실행하는데 전혀 문제없다. 즉, defaults.yaml을 설정하지 않아도 된다는 말. 조금 달리 표현하면 hosts.yaml의 설정 값 -> groups.yaml -> defaults.yaml으로 우선순위가 결정된다. 즉, hosts.yaml에서 설정한 값과 defaults.yaml에서 동일하게 설정한 값이 있다면 hosts.yaml의 내용이 선택되는 것.

 

hosts.yaml:

router1:
    hostname: 192.168.1.1
    groups:
        - cisco_ios
    data:
        location: datacenter1

router2:
    hostname: 192.168.1.2
    groups:
        - cisco_ios
    data:
        location: datacenter2

 

groups.yaml:

cisco_ios:
    platform: ios
    username: admin
    password: cisco
    connection_options:
        netmiko:
            platform: cisco_ios
            extras:
                secret: cisco

 

defaults.yaml:

---
# 기본 접속 정보
username: admin
password: cisco
port: 22

# 타임아웃 설정
connection_timeout: 10
timeout_ops: 60
retry_timeout: 30
retry_attempts: 3

# 기본 플랫폼 정보
platform: ios

# 공통 데이터
data:
    timezone: "Asia/Seoul"
    domain: "example.com"
    dns_servers:
        - "8.8.8.8"
        - "8.8.4.4"
    ntp_servers:
        - "0.pool.ntp.org"
        - "1.pool.ntp.org"

# 연결 옵션
connection_options:
    netmiko:
        platform: cisco_ios
        extras:
            secret: cisco
            fast_cli: false
            session_log: "session.log"
            global_delay_factor: 2
    napalm:
        platform: ios
        extras:
            optional_args:
                secret: cisco
                transport: ssh

3.2 Task와 Runner 개념

Nornir에서 Task는 실행할 작업의 단위이며, Runner는 이러한 Task를 실행하는 방식을 정의한다. Runner에는 기본값인 ThreadRunner, SerialRunner, 그리고 ProcessRunner가 있는데 ThreadRunner에 대해서만 기록하려 한다.

 

간단한 Task 예제:

from nornir import InitNornir
from nornir_netmiko import netmiko_send_command

def simple_task(task):
    # 장비에 show version 명령어 실행
    result = task.run(
        task=netmiko_send_command,
        command_string="show version"
    )
    return result

# Nornir 초기화
nr = InitNornir(config_file="config.yaml")

# Task 실행
result = nr.run(task=simple_task)
 

3.3 Result 객체 다루기

Nornir의 실행 결과는 Result 객체로 반환되어 예제 코드는 아래와 같다. 객체로 반환되었다는 말은 Result 객체 안의 속성을 사용할 수 있다는 말이다. 

 

예를 들어, Result 객체의 result에 바로 접근하게 되면 명령어 실행 결과를 바로 볼 수 있다.

# show version 명령어 실행 결과
print(host_result.result)
# 출력: 'Cisco IOS Software, Version 15.2(4)M1...'

# configuration 변경 결과
print(host_result.result)
# 출력: {'success': True, 'changed_lines': ['interface GigabitEthernet0/1']}

 

두 번째 예제는 changed 속성을 출력한 것인데, 장비의 구성이 변경 (configuration 발생)이 되었는지 유무를 판단할 수 있다. 쉽게 말해, show version과 같은 read 명령어를 수행하면 changed의 값은 False로 반환되는 것이다.

# show 명령어의 경우
print(host_result.changed)  # False

# configuration 변경의 경우
print(host_result.changed)  # True

 

그리고 마지막 속성은 failed이다. 이 것은 task 실행의 성공/실패 여부를 판단해주는 변수이며 task 수행이 실패했을 경우 자세한 에러 내용은 exception 변수를 통해서 확인할 수 있다.

# 성공적인 실행
print(host_result.failed)  # False

# 실패한 실행
print(host_result.failed)  # True
print(host_result.exception)  # 에러 상세 내용

 

def process_results(result):
    # 모든 호스트의 결과 처리
    for host, host_results in result.items():
        print(f"\n==== Host: {host} ====")
        
        # 각 Task의 결과 처리 (여러 Task를 실행한 경우)
        for task_result in host_results:
            print(f"\nTask: {task_result.name}")
            
            # 실행 결과 확인
            if task_result.failed:
                print(f"❌ Task failed!")
                print(f"Error: {task_result.exception}")
                continue
                
            # 변경 사항 확인
            if task_result.changed:
                print("✔️ Configuration changed")
            else:
                print("ℹ️ No changes made")
                
            # 결과 출력
            print("\nOutput:")
            print(task_result.result)
            
            # 추가 메타데이터
            print("\nMetadata:")
            print(f"Duration: {task_result.duration}")
            print(f"Start Time: {task_result.start_time}")
            print(f"End Time: {task_result.end_time}")

# 사용 예시
nr = InitNornir(config_file="config.yaml")
result = nr.run(task=some_task)
process_results(result)

 

아래 예제는 MultiResult 객체를 설명하기 위한 예제이다. task=netmiko_send_command를 사용하여 복수개의 명령을 실행하거나 task=napalm_cli 옵션을 사용하면 MultiResult 객체를 사용해야 한다.

def complex_task(task):
    # Subtask 1: show version
    task.run(
        task=netmiko_send_command,
        command_string="show version"
    )
    
    # Subtask 2: show interfaces
    task.run(
        task=netmiko_send_command,
        command_string="show interfaces"
    )

# 결과 처리
result = nr.run(task=complex_task)
for host, host_results in result.items():
    print(f"\nHost: {host}")
    
    # MultiResult 객체 처리
    for task_result in host_results:
        # 각 subtask의 결과에 인덱스로 접근
        version_result = task_result[0].result
        interfaces_result = task_result[1].result
        
        print(f"Version Info: {version_result[:100]}...")
        print(f"Interface Info: {interfaces_result[:100]}...")
from nornir_napalm.plugins.tasks import napalm_cli

def get_device_info(task):
    # 여러 명령어를 한 번에 실행
    result = task.run(
        task=napalm_cli,
        commands=[
            "show version",
            "show ip interface brief",
            "show running-config"
        ]
    )
    
    # NAPALM CLI 결과 처리
    if not result.failed:
        # result.result는 딕셔너리 형태로 반환됨
        version_output = result[0].result["show version"]
        interface_output = result[0].result["show ip interface brief"]
        config_output = result[0].result["show running-config"]
        
        # 결과 처리
        print(f"Version: {version_output}")
        print(f"Interfaces: {interface_output}")
        print(f"Config: {config_output}")

# 실행
nr = InitNornir(config_file="config.yaml")
result = nr.run(task=get_device_info)

# 전체 결과 처리
for host, multi_result in result.items():
    if multi_result.failed:
        print(f"❌ Host {host} failed: {multi_result.exception}")
    else:
        print(f"✔️ Host {host} succeeded")
        # NAPALM 결과는 multi_result[0].result에 딕셔너리로 저장됨
        for command, output in multi_result[0].result.items():
            print(f"\nCommand: {command}")
            print("-" * 50)
            print(output)

 

napalm_cli의 옵션을 사용할 때는 특징은 위의 예제에서 보는 것과 같이 여러 명령어를 한 번에 실행가능하다. 그리고 그 결과는 {"command": "output"} 형태의 딕셔너리로 반환된다. 또한, MultiResult 객체의 첫 번째 요소에 모든 명령어 결과가 반환된다는 점이 특징이다. 

 

3.4 Filter 사용

Nornir에서는 F라는 객체를 이용해 복잡한 filtering이 필요할 경우 사용한다. 예를 들어, 아래의 예제를 보자. 플랫폼 또는 위치를 가지고 필터링을 한다던지 platform과 위치 두 가지 다중 조건 필터링을 한다던지 Pythonic한 필터 구현이 가능하다.

from nornir.core.filter import F

# 플랫폼 기준 필터링
ios_devices = nr.filter(F(platform="ios"))

# 위치 기준 필터링
dc1_devices = nr.filter(F(data__location="datacenter1"))

# 다중 조건 필터링 (AND)
target_devices = nr.filter(
    F(platform="ios") & F(data__location="datacenter1")
)

# 다중 조건 필터링 (OR)
edge_devices = nr.filter(
    F(role="edge") | F(role="border")
)

# NOT 조건
non_ios_devices = nr.filter(~F(platform="ios"))

# 중첩 조건
complex_filter = nr.filter(
    (F(platform="ios") & F(data__location="datacenter1")) |
    (F(platform="nxos") & F(data__location="datacenter2"))
)

# 리스트 값 포함 여부
specific_devices = nr.filter(
    F(name__in=["router1", "router2", "router3"])
)

 

또한 우리가 사용하는 hosts.yaml 파일은 데이터가 계층적으로 설정되어있다. 이 것을 F객체를 이용해 직접 접근이 가능한데 아래와 같다.

# hosts.yaml
router1:
    hostname: 192.168.1.1
    groups:
        - cisco_ios    # 이 부분이 groups.yaml과 연결됨
    data:
        site:
            region: APAC

# groups.yaml
cisco_ios:
    data:
        site:
            region: EU    # 이 데이터는 F(data__site__region="EU")로는 직접 접근 불가
                          # F(groups__cisco_ios__data__site__region="EU")로 가능
                          
# data 필드의 중첩된 값 접근
nr.filter(F(data__site__region="APAC"))

# 다중 계층 접근
nr.filter(F(data__network__mgmt__vlan=100))

 

Nornir에서는 hosts.yaml을 primary 데이터 소스로 보기때문에 F객체에서 data로 시작하는 경우 hosts.yaml 파일을 우선적으로 확인한다.

반응형