MQTT failover
MQTT over WIZnet Ethernet with automatic Wi-Fi failover, on ESP-IDF.
<Draft>
케이블 하나에 걸린 라인 — ESP32-S3 MQTT 이더넷 + Wi-Fi 자동 Failover
No-Downtime MQTT on ESP32-S3 — WIZnet Ethernet with Automatic Wi-Fi Failover ESP-IDF의 표준 MQTT 예제를, 이더넷과 Wi-Fi 두 인터페이스 위에서 끊기지 않게 돌린다. 코드: https://github.com/Wiznet/ESP32_DevKit_SoM_MQTT_Failover
1. 케이블 하나에 걸린 라인
현장에 나간 보드가 MQTT로 브로커에 붙어 있다. 누가 랙 뒤에서 케이블을 건드린다. 세션이 죽는다.
재접속 로직이 있으니 언젠가는 돌아온다. 문제는 "언젠가"다. 그리고 케이블이 다시 꽂히기 전까지는 아무리 기다려도 돌아오지 않는다. 이런 보드 옆에는 보통 Wi-Fi AP가 이미 떠 있다. 그렇다면 답은 뻔해 보인다.
2. What it does
ESP-IDF의 examples/protocols/mqtt 예제를 가져와서, 두 가지만 바꿨다.
- 이더넷을
example_connect()대신 WIZnetwsm_driver컴포넌트로 올린다 (W5500 또는 W6300). - 그 옆에 Wi-Fi STA를 하나 더 올리고, 둘 중 살아 있는 쪽으로 MQTT 세션이 자동으로 옮겨가게 한다.
MQTT 쪽 코드는 원본 그대로다. 같은 mqtt_event_handler switch, 같은 mqtt_app_start(), 같은 esp_mqtt_client API. 이 백엔드에서 WIZnet 칩은 그냥 SPI로 붙은 이더넷 MAC이고 TCP/IP는 ESP32-S3의 LwIP가 소유하기 때문에, 두 인터페이스 모두 평범한 esp_netif이고 ESP-MQTT는 그 위에서 수정 없이 돈다.
평소에는 이더넷으로 나간다. 케이블이 빠지면 Wi-Fi로 넘어간다. 다시 꽂으면 이더넷으로 돌아온다. 어느 쪽으로 나가고 있는지는 브로커에서 바로 보인다:
topic/qos0 seq=40 via=ETH_DEF 192.168.11.2topic/qos0 seq=41 via=ETH_DEF 192.168.11.2 <- 여기서 케이블을 뽑았다topic/qos0 seq=44 via=WIFI_STA_DEF 192.168.0.423. Hardware & wiring
ESP32-S3 보드 하나와 WIZnet W5500 또는 W6300 모듈이면 된다. 네 가지 보드 조합을 지원하고, menuconfig에서 보드를 고르면 칩 종류와 SPI 배선이 전부 따라온다.
| Board | MOSI | MISO | IO2 | IO3 | SCLK | CS | RST | INT |
|---|---|---|---|---|---|---|---|---|
| W5500 Dev-kit | 11 | 13 | — | — | 12 | 10 | 9 | 14 |
| W5500 SoM | 11 | 13 | — | — | 12 | 10 | 9 | 14 |
| W6300 Dev-kit | 11 | 13 | 14 | 9 | 12 | 10 | 21 | 8 |
| W6300 SoM | 34 | 35 | 36 | 37 | 42 | 41 | 21 | 33 |
SPI host는 모든 보드에서 SPI2, 클럭은 33 MHz다. IO2/IO3는 W6300을 Quad QSPI로 쓸 때만 존재한다. 목록에 없는 보드라면 Custom을 고르면 핀 값이 편집 가능한 항목으로 열린다 (고정 보드를 고르면 읽기 전용으로 표시된다).
<!-- TODO: 실물 배선 사진 -->
4. 딱 한 줄이 바뀐다
원본 예제는 네트워크를 이렇게 올린다.
ESP_ERROR_CHECK(nvs_flash_init());ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());ESP_ERROR_CHECK(example_connect()); /* Wi-Fi 또는 Ethernet, menuconfig에 따라 */mqtt_app_start();여기서는 이렇게 된다.
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(load_net_info());wiznet_net_init(&g_net_info); /* 이더넷 먼저 */wifi_net_init(CONFIG_EXAMPLE_WIFI_SSID, CONFIG_EXAMPLE_WIFI_PASSWORD);
set_route_priorities();/* ... 링크가 하나라도 올라올 때까지 대기 ... */mqtt_app_start();함정 하나: esp_netif_init()을 지워야 한다
esp_netif_init()과 esp_event_loop_create_default()가 의도적으로 사라졌다. wiznet_net_init()이 둘 다 자기가 호출하고 ESP_ERROR_CHECK로 감싸는데, esp_event_loop_create_default()를 두 번 부르면 ESP_ERR_INVALID_STATE가 돌아온다. 즉 원본 예제의 그 두 줄을 그대로 남겨두면 부팅 도중 펌웨어가 abort한다. (esp_netif_init()만 남기는 건 무해하다. idempotent라서.)
함정 둘: 이더넷을 Wi-Fi보다 먼저 올려야 한다
같은 이유다. wiznet_net_init()은 이미 초기화된 이벤트 루프에 대해 하드 페일하는 쪽이고, wifi_net_init()은 그 상황을 견딘다. 그래서 순서가 고정이다.
5. Failover의 절반: route_prio
esp_netif는 default netif — 더 구체적인 경로가 없는 트래픽이 나가는 인터페이스 — 를 살아 있는 것들 중 route_prio가 가장 높은 것으로 고른다. 그리고 하나가 죽으면 알아서 옮긴다. 이게 failover 메커니즘의 전부다.
여기에 함정이 하나 있다. IDF의 기본값은 다음과 같다.
| 인터페이스 | 기본 route_prio |
|---|---|
Wi-Fi (ESP_NETIF_INHERENT_DEFAULT_WIFI_STA) | 100 |
Ethernet (ESP_NETIF_INHERENT_DEFAULT_ETH) | 50 |
기본값에서는 Wi-Fi가 이긴다. 그러니까 "이더넷을 우선으로 쓰겠다"는 것은 이더넷을 켜는 것으로 되는 일이 아니라, 이더넷 값을 100 위로 올리는 일이다. 이걸 놓치면 두 인터페이스가 다 멀쩡한데 트래픽은 조용히 엉뚱한 쪽으로 나간다.
#define PRIO_PREFERRED 150#define PRIO_BACKUP 10
static void set_route_priorities(void){ esp_netif_t *eth = esp_netif_get_handle_from_ifkey("ETH_DEF"); esp_netif_t *wifi = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
#if CONFIG_EXAMPLE_PRIMARY_ETHERNET const int eth_prio = PRIO_PREFERRED, wifi_prio = PRIO_BACKUP;#else const int eth_prio = PRIO_BACKUP, wifi_prio = PRIO_PREFERRED;#endif
if (eth) esp_netif_set_route_prio(eth, eth_prio); if (wifi) esp_netif_set_route_prio(wifi, wifi_prio);}어느 쪽을 선호할지는 menuconfig의 Preferred interface에서 고른다.
6. 나머지 절반: 소켓은 따라오지 않는다
인터페이스 상태가 바뀌면 두 가지가 일어나야 하는데, esp_netif는 첫 번째만 해준다.
- 기본 경로가 옮겨간다 — 자동.
route_prio가 해준다. - MQTT 세션이 옮겨간다 — 자동이 아니다.
TCP 연결은 열릴 때 쓴 소스 주소에 묶여 있어서 마이그레이션되지 않는다. 그런데 두 방향의 동작이 다르다.
- 링크가 죽을 때: LwIP가 pcb를 abort한다 (
netif_set_addr→tcp_netif_ip_addr_changed). ESP-MQTT는 연결이 끊긴 걸 보고 알아서 재접속한다. 여기까지는 문제없다. - 링크가 돌아올 때: 아무 일도 일어나지 않는다. LwIP의 소스 주소 라우팅 훅이 살아 있는 연결을 소켓이 이미 들고 있는 주소의 netif에 고정시켜버린다. 그래서 백업으로 넘어간 세션은 선호 인터페이스가 돌아와도 백업에 계속 남는다.
그래서 양쪽 엣지에서 클라이언트를 한 번 흔들어준다.
static void nudge_reconnect(const char *why){ if (s_client == NULL) return; /* mqtt_app_start() 전에 온 이벤트 */ ESP_LOGI(TAG, "%s — reconnecting MQTT so it picks the current route", why); esp_mqtt_client_reconnect(s_client);}
static void iface_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data){ if (base == ETH_EVENT && id == ETHERNET_EVENT_CONNECTED) nudge_reconnect("Ethernet link up"); if (base == WIFI_EVENT && id == WIFI_EVENT_STA_CONNECTED) nudge_reconnect("Wi-Fi associated");}재접속에는 짧은 단절이 따르지만, 선호 인터페이스로 돌아오는 방법은 이것뿐이다.
그리고 하지 말아야 할 것: network.if_name
ESP-MQTT 설정에는 소켓을 특정 인터페이스에 바인딩하는 network.if_name이 있다. 이 예제는 일부러 비워둔다. 이걸 채우면 SO_BINDTODEVICE로 세션이 그 인터페이스에 고정되고, failover가 통째로 무력화된다. 비워두면 새로 열리는 소켓이 그 시점의 기본 경로를 따라간다 — 우리가 원하는 게 정확히 그거다.
7. 얼마나 빨리 알아채나
재미있는 케이스는 케이블이 뽑히는 경우가 아니다. 링크는 UP인데 그 뒤의 경로가 죽는 경우다 — 상위 라우터 다운, 브로커 호스트 사망, 방화벽 변경. 이때는 netif 이벤트가 하나도 발생하지 않는다. 경로도 소켓도 스스로 움직이지 않는다. 타이머만이 이걸 눈치챈다.
그래서 라이브러리 기본값을 그대로 두지 않고 두 겹으로 깔았다.
.session = { .keepalive = CONFIG_EXAMPLE_MQTT_KEEPALIVE_S, /* 기본 20 s (IDF 기본값 120 s) */},.network = { .reconnect_timeout_ms = CONFIG_EXAMPLE_MQTT_RECONNECT_MS, /* 3000 ms (IDF 기본값 10000) */ .tcp_keep_alive_cfg = { .keep_alive_enable = true, .keep_alive_idle = 5, /* s */ .keep_alive_interval = 3, /* s */ .keep_alive_count = 3, },},| 계층 | 값 | 잡아내는 것 |
|---|---|---|
| MQTT keep-alive | 20 s (절반 지점에 PING, 만료에 포기) | 브로커 쪽의 침묵 |
| TCP keep-alive | idle 5 + interval 3 × count 3 ≈ 14 s | MQTT 트래픽과 무관하게, 전송 계층에서 죽은 경로 |
| 재접속 지연 | 3 s 고정 (백오프 없음) | 복구 속도 |
ESP-MQTT의 기본 keep-alive 120초는 죽은 경로가 2분간 발각되지 않는다는 뜻이다. MQTT keep-alive는 "브로커가 조용하다"만 측정하고, half-open TCP는 재전송만으로 수 분을 버틸 수 있다. 그래서 두 계층을 같이 쓴다.
8. MACRAW냐 TOE냐 — 왜 고를 여지가 없나
WIZnet wsm_driver는 두 가지 백엔드를 제공한다.
- esp_eth MACRAW + 소프트웨어 LwIP — 칩은 MAC 역할, TCP/IP는 ESP32가 담당. 표준 BSD 소켓이므로 ESP-MQTT가 그대로 동작한다. ← 이 예제가 쓰는 것
- TOE — 칩이 TCP/IP를 직접 처리하고, 13개의
lwip_*심볼을-Wl,--wrap으로 가로채서 접근한다.
ESP-MQTT는 esp-tls와 tcp_transport를 거쳐 LwIP에 닿는데, 그 경로에서 getaddrinfo(), select(), fcntl()을 부른다. 이 셋은 wrap 대상이 아니다. 그래서 TOE에서는 호출 체인이 중간에 소프트웨어 스택으로 빠지고, 칩이 실제로 쥐고 있는 연결을 잃어버린다.
failover 관점에서는 더 심각하다. wrap이 걸려 있으면 칩이 모든 소켓을 소유하므로, Wi-Fi 소켓이 존재할 자리 자체가 없다.
조용히 안 붙는 펌웨어를 만드는 대신, 빌드를 세운다.
#if !defined(CONFIG_WSM_DRIVER_BACKEND_ETH)#error "Select Component config -> WIZnet WSM Driver -> Network backend -> esp_eth MACRAW + software LwIP. ESP-MQTT cannot run on the TOE backend."#endif칩의 하드웨어 TCP/IP가 꼭 필요하다면, 컴포넌트에 포함된 자체 examples/mqtt를 쓰면 된다. 그쪽은 소켓 vtable에 맞춰 작성된 작은 MQTT 엔진을 들고 있어서 두 백엔드 모두에서 동작한다.
9. Build & configure
idf.py set-target esp32s3idf.py menuconfigidf.py -p PORT flash monitorExample Configuration
| 항목 | 기본값 | 비고 |
|---|---|---|
| connect using Ethernet / WiFi | 둘 다 y | 둘 다 켜야 failover가 의미를 가진다 |
| Preferred interface | Ethernet | 둘 다 켰을 때만 나타난다 |
| MQTT broker URI |
| 스킴이 전송 방식을 정한다 (mqtt/mqtts/ws/wss) |
| Use TLS | n | IDF 인증서 번들을 붙인다. 수십 KB를 쓴다 |
| WiFi SSID / Password | — | 컴포넌트의 Wi-Fi 백엔드는 WPA2-PSK 이상을 요구한다 |
| MQTT keep-alive / Publish period / Reconnect delay | 20 s / 2000 ms / 3000 ms | 7·10번 참조 |
| Ethernet MAC / IP / Netmask / Gateway / DNS |
|
|
Component config → WIZnet WSM Driver
- Board — W5500 Dev-kit(기본) / W5500 SoM / W6300 Dev-kit / W6300 SoM / Custom
- Network backend — esp_eth MACRAW 고정 (8번 참조)
중요: failover가 의미를 가지려면 브로커가 두 인터페이스 모두에서 도달 가능해야 한다. URI에 호스트명을 쓴다면 DNS도 양쪽에서 닿아야 한다.
PC에서 브로커를 먼저 띄운다.
mosquitto -v10. Demo: failover를 눈으로 보기
원본 예제는 SUBACK을 받았을 때 한 번 publish한다. 그걸로는 전환에 걸린 시간도, 어느 경로로 나갔는지도 알 수 없다. 그래서 주기 publish 태스크를 하나 붙였고, 페이로드에 지금 나가고 있는 인터페이스 이름을 적는다.
static void describe_route(char *out, size_t len){ esp_netif_t *netif = esp_netif_get_default_netif(); esp_netif_ip_info_t ip;
if (netif == NULL) { snprintf(out, len, "via=none"); return; } if (esp_netif_get_ip_info(netif, &ip) != ESP_OK) { snprintf(out, len, "via=%s", esp_netif_get_ifkey(netif)); return; } snprintf(out, len, "via=%s " IPSTR, esp_netif_get_ifkey(netif), IP2STR(&ip.ip));}이 값을 매 publish마다 새로 읽는다. 그게 바로 시험 대상이기 때문이다.
브로커 쪽에서 구독한다.
mosquitto_sub -h <broker> -t 'topic/qos0' -v그리고 케이블을 뽑는다.
topic/qos0 seq=40 via=ETH_DEF 192.168.11.2topic/qos0 seq=41 via=ETH_DEF 192.168.11.2 <- 케이블 뽑음topic/qos0 seq=44 via=WIFI_STA_DEF 192.168.0.42seq의 빈 구간이 곧 단절 시간이고, via= 가 실제로 경로가 바뀌었음을 증명한다. 다시 꽂으면 via=ETH_DEF로 돌아온다 (6번의 nudge 덕분이다).
여기서 QoS를 일부러 0으로 둔 이유
publish는 QoS 0, outbox 미사용이다. 연결이 끊긴 동안 시도한 publish는 나중에 배달되는 대신 그냥 버려진다. QoS 1과 outbox를 켜면 메시지는 살지만 빈 구간이 사라져서 실제 단절 시간을 볼 수 없게 된다. 데모의 목적이 그 구멍을 보는 것이므로, 구멍을 가리지 않았다.
<!-- TODO: mosquitto_sub 터미널 캡처 / 케이블 뽑는 짧은 영상 -->
부팅 로그는 이렇게 나온다.
I (312) mqtt_example: [APP] Startup..I (322) mqtt_example: [APP] IDF version: v6.0.2I (512) w5500_eth: Ethernet startedI (612) mqtt_example: Preferred interface: EthernetI (632) mqtt_example: Waiting for a network link...I (1892) w5500_eth: Ethernet got IP 192.168.11.2I (1902) mqtt_example: Link is upI (2012) mqtt_example: MQTT_EVENT_CONNECTEDI (4012) mqtt_example: published: seq=0 via=ETH_DEF 192.168.11.211. Results
TEST는 아래와 같이 구성하였고, Ethernet의 우선순위를 높게 설정하여 Ethernet인터페이스를 메인으로 Wifi를 백업 용으로 설정하였다.
실제 Ethernet 통신 중 Ehertnet 케이블이 해제 된 상황
실제 Wifi 통신 중에 다시 Ethernet 케이블이 연결된 상황.
| 시나리오 | 감지 경로 | 전환 시간 | 유실된 seq 개수 |
|---|---|---|---|
| 이더넷 케이블 분리 → Wi-Fi | netif 이벤트 (즉시) + 재접속 3 s | 측정 필요 | 측정 필요 |
| 링크는 UP, 상위 경로만 다운 | TCP keep-alive ≈ 14 s | 측정 필요 | 측정 필요 |
| 브로커 프로세스 종료 | MQTT keep-alive 20 s | 측정 필요 | 측정 필요 |
| 이더넷 복귀 → Wi-Fi에서 되돌아옴 |
| 측정 필요 | 측정 필요 |
(publish 주기 2초 기준. 유실 개수는 seq 점프 폭으로 그대로 읽힌다.)
12. Gotchas 모음
- 멀티캐스트 MAC 거부 — MAC 첫 옥텟의 최하위 비트가 서 있으면 멀티캐스트 주소이고, 스테이션이 자기 주소로 쓸 수 없다. 링크는 올라오는데 아무것도 라우팅되지 않는 증상이 나온다. 그래서 부팅 때 잡아서 이름과 함께 에러를 낸다.
- dotted-quad 파싱 —
sscanf는 첫 불일치에서 멈추고도 변환 4개를 성공으로 보고한다."1.2.3.4.5"나"1.2.3.4x"를 걸러내려면 뒤에%c를 하나 더 붙여야 한다. - 주소를 menuconfig 문자열로 받는 이유 —
wiznet_net_init()은 주소 필드가 바이트 배열인wiz_NetInfo를 받는다. Kconfig 문자열은 어차피 파싱해서 되돌려야 하므로, 파싱을 부팅 시점에 하고 잘못된 항목은 이름을 찍고 부팅을 세운다. wsm_driver를 릴리스가 아니라main에서 가져온다 — 레지스트리의 1.1.0은 이 예제가 의존하는 보드 선택 기능보다 앞선다.dependencies.lock이 정확한 커밋을 고정하므로 빌드 재현성은 유지된다. 락을 지우면 그 시점의main을 따라간다.- ESP-MQTT는 이제 managed component다 — IDF v6.0에서 트리 밖으로 나갔다. 그래서
이espressif/mqtt에 의존한다 (원본 예제와 동일). - 정적 주소 고정 — 이 백엔드는 DHCP 클라이언트를 무조건 정지시킨다. 이더넷 쪽은 정적 주소를 줘야 하고, 브로커와 같은 서브넷에 올려야 한다.
13. 어디에 쓸 수 있나 / Next
이더넷이 기본이지만 끊기면 곤란한 자리라면 그대로 쓸 수 있다 — 산업용 게이트웨이, 무인 키오스크, 원격 계측 설비, 공장 라인의 상태 수집 노드. 유선을 우선으로 쓰되 Wi-Fi를 보험으로 두는 구성은 배선 사고 하나에 라인 전체가 침묵하는 일을 막는다.
다음 단계로 생각하고 있는 것들:
- 두 인터페이스 동시 세션 —
wsm_driver의 자체examples/mqtt는 인터페이스마다 MQTT 클라이언트를 하나씩 띄운다. failover 대신 이중화로 가는 방향. - DHCP 지원 — 지금은 이더넷이 정적 주소 고정이다.
- OTA — failover가 있는 링크 위에서의 펌웨어 업데이트.
Things used in this project
Hardware
- ESP32-S3 개발 보드
- WIZnet W5500 Dev-kit / W5500 SoM / W6300 Dev-kit / W6300 SoM 중 하나
- 이더넷 케이블, 2.4 GHz Wi-Fi AP (WPA2-PSK 이상)
Software / Components
- ESP-IDF v6.0.2
wiznet/wsm_driver(main 브랜치,dependencies.lock으로 커밋 고정)espressif/mqtt(ESP-MQTT, managed component)- mosquitto (브로커 및
mosquitto_sub/mosquitto_pub)
Schematics
보드별 SPI 핀맵은 3번 표 참조. <!-- TODO: 배선도/사진 첨부 -->
Code
전체 소스: https://github.com/Wiznet/ESP32_DevKit_SoM_MQTT_Failover
