← 전체 글
MQTT/약 15분 읽기/— 조회

복제한 edge PC 4대가 서로를 끊어냈다: MQTT client ID와 ACL 설계

MQTT client ID는 23바이트만 보장된다. 중복 ID, 너무 넓은 wildcard, retained command, 소리 없이 거부되는 publish까지 SCADA edge에서 실제로 터지는 것들.

MQTTSCADA네트워킹태그문제 해결

Line 4개가 동시에 튀었다

Packaging line 4개. Edge PC image를 한 대에서 만들어 나머지 세 대에 복사했다. MQTT client ID까지 같이 복사됐다. Broker log에는 같은 client ID가 서로 다른 IP에서 몇 초마다 connect / disconnect를 반복하고 있었다. 운전자 화면에서는 line이 랜덤하게 offline / online으로 튀는 것처럼 보였다.

이건 broker 버그가 아니다. MQTT 3.1.1 §3.1.4의 [MQTT-3.1.4-2]가 그렇게 하라고 정해 놓은 동작이다. 이미 접속된 client ID로 새 CONNECT가 오면 broker는 기존 client를 끊어야 한다. MQTT 5.0이면 끊기는 쪽에 DISCONNECT reason code 0x8E (Session taken over)가 간다. 최소한 이유는 남는다는 뜻이다.

Topic 이름과 payload 형식에는 다들 시간을 쓴다. Broker 권한과 client ID는 IT 설정으로 넘긴다. 순서가 거꾸로다. Broker는 message pipe가 아니라 PLC gateway, edge collector, historian, MES, dashboard, vendor 노트북 사이의 enforcement point다. HMI tag write 권한과 같은 급으로 다뤄야 한다.

Client ID: 규격이 보장하는 건 23바이트뿐

MQTT 3.1.1 §3.1.3.1의 [MQTT-3.1.3-5]는 broker가 1~23 UTF-8 byte 길이, [0-9a-zA-Z] 문자만 허용해도 규격을 지킨 것으로 본다. 그보다 길거나 ., -, _가 섞인 ID를 받는 건 broker 재량(MAY)이다.

mosquitto, EMQX, HiveMQ는 다 받아 준다. 그래서 plant1.packaging.edgegw.01(26 byte) 같은 이름이 현장에 널려 있다. 지금 broker에서 돌아가는 것과 규격이 보장하는 것은 다르다. Broker를 바꾸거나 embedded gateway에 들어간 작은 MQTT stack을 만나면 여기서 터진다. MQTT 5.0 broker라면 거부 이유가 CONNACK reason code 0x85 (Client Identifier not valid)로 온다.

23 byte 안에 site, area, 기능, instance를 다 넣으려면 이렇게 된다.

p1pkgEdgeGw01     edge gateway, packaging line 1
p1pkgScadaPri     SCADA server, primary
p1pkgScadaStb     SCADA server, standby
p1MesConnProd     MES connector, production

읽기 좋은 dotted 이름을 계속 쓸 거면 그건 선택이지, 규격이 지켜 주는 게 아니다. 인수인계 문서에 "이 ID 길이는 현재 broker 재량에 의존한다"고 한 줄 적어 두는 편이 낫다. mqtt_client, edge01, vendor 기본값은 어느 쪽이든 피한다. Broker log에서 누가 누군지 못 찾는다.

빈 client ID도 함정이다. CleanSession=1일 때만 쓸 수 있다. CleanSession=0에 빈 ID로 붙으면 broker는 CONNACK return code 0x02 (Identifier rejected)를 보내고 연결을 닫아야 한다 [MQTT-3.1.3-8]. Edge gateway는 broker가 붙여 주는 임의 ID를 쓰면 안 된다. ACL에서 지목할 대상이 사라진다.

사용자 이름보다 client 역할부터 정리한다

ACL rule을 쓰기 전에 broker에 붙는 역할을 먼저 적는다.

역할PublishSubscribe비고
Edge gatewayTelemetry, birth, Last Will, 장비 statusCommand, configuration보통 line, cell, skid 단위로 둔다
SCADA serverCommand, acknowledge, operator actionTelemetry와 status다른 area 데이터를 받을 필요가 없다
Historian collector없거나 health status 정도Telemetry topic대부분 read-only가 맞다
MES connectorWork order, recipe, lot contextEquipment state, count, eventNamespace 경계가 중요하다
Engineering toolTest topicTest topic과 diagnostics운영 credential을 같이 쓰면 안 된다
Dashboard없음집계 topic 또는 read-only topicRaw control topic wildcard는 피한다

이 표가 있으면 ACL review가 쉬워진다. 시운전 중 임시로 만든 vendor 노트북, Node-RED flow, report job 같은 숨은 client도 이때 드러난다.

mosquitto라면 표를 rule로 옮기는 가장 짧은 방법이 pattern이다. %u는 username, %c는 client ID로 치환된다.

pattern write plant1/%u/telemetry/#
pattern read  plant1/%u/command/#

Client가 늘어도 ACL 파일을 다시 안 건드린다. 대신 username과 topic 구조가 어긋나면 조용히 다 막히니, 이름 규칙을 먼저 고정해야 한다.

Topic 권한은 방향을 나눈다

Area가 같다는 이유로 모든 topic에 publish와 subscribe를 둘 다 주면 안 된다.

plant1/packaging/line1/telemetry/#      edge publish, SCADA/historian subscribe
plant1/packaging/line1/status/#         edge publish, SCADA/MES subscribe
plant1/packaging/line1/command/#        SCADA 또는 MES publish, edge subscribe
plant1/packaging/line1/config/#         승인된 publisher, edge subscribe
plant1/packaging/line1/test/#           engineering 전용

Namespace 모양은 현장마다 달라도 된다. 방향은 달라선 안 된다. Historian collector가 command/#에 publish할 이유는 없다.

Sparkplug B를 쓰면 namespace는 설계 대상이 아니다. Eclipse Sparkplug 3.0이 spBv1.0/{group_id}/{message_type}/{edge_node_id}/{device_id} 형태와 message type(NBIRTH, NDEATH, DBIRTH, DDEATH, NDATA, DDATA, NCMD, DCMD, STATE)을 고정한다. ACL은 그 모양에 맞춰 쓴다. Command 방향은 NCMD와 DCMD이고, 거기에 publish하는 건 host application뿐이어야 한다. Sparkplug는 NDEATH를 MQTT Will message로 등록하도록 요구하고, 그 Will은 QoS 1, retain false다. Retain을 켜면 규격 위반이고 죽지도 않은 node가 계속 죽은 것으로 보인다.

plant1/# 하나가 몇 달을 산다

Dashboard나 vendor tool이 시운전 편의 때문에 plant1/#를 subscribe한다. 몇 달 뒤에도 그 rule이 살아 있고, 다른 area의 raw telemetry, alarm topic, command response까지 다 보인다.

#가 들어간 rule은 따로 review한다. 전부 owner와 이유가 있어야 한다.

Wildcard가 어디까지 닿는지도 정확히 알아 둘 필요가 있다. MQTT 3.1.1 §4.7.2의 [MQTT-4.7.2-1]은 #나 +로 시작하는 topic filter가 $로 시작하는 topic name과 match되지 않도록 정해 놓았다. 그래서 # 하나로는 broker의 $SYS/ 통계가 새지 않는다. 반대로 broker 진단을 보려면 $SYS/#를 따로 허용해야 하고, 그건 별도 권한으로 다뤄야 한다.

MQTT 5.0 broker가 wildcard subscription 자체를 막아 놓은 경우도 있다. 그때 SUBACK reason code는 0xA2 (Wildcard Subscriptions not supported)다. Dashboard가 조용히 빈 화면을 띄우면 이 코드부터 본다.

Last Will은 즉시 오지 않는다

여기서 시간 제일 많이 버린다. 케이블을 뽑으면 Last Will이 바로 나올 거라고 생각한다. 아니다.

Broker는 TCP half-open을 알아채지 못한다. Keep Alive로 판단한다. MQTT 3.1.1 §3.1.2.10에서 Keep Alive는 16 bit 초 값이고 최대 65535초다. Keep Alive가 0이 아니면 broker는 그 값의 1.5배 동안 control packet이 없을 때 연결을 끊어야 한다 [MQTT-3.1.2-24]. Keep Alive 60초면 Will publish가 최대 90초 늦는다. SCADA 쪽 offline 판정 timeout을 30초로 잡아 놨다면 그 60초 차이만큼 화면이 거짓말을 한다.

MQTT 5.0에는 Will Delay Interval(Will property 0x18)이 하나 더 있다. 짧은 재접속에 Will이 튀는 걸 막으려고 일부러 늦추는 값이다. 유용하지만 SCADA offline 판정 시간과 맞춰 놓지 않으면 화면이 계속 green으로 남는다.

시험은 client를 정상 종료해서 하는 게 아니다. Network를 끊고 초시계로 잰다. 그 숫자를 인수인계 문서에 적는다.

Retained command는 다시 실행된다

Retained command는 edge client가 재접속할 때 그대로 다시 전달된다. 명령이 재실행되거나 startup logic이 헷갈린다.

MQTT 5.0에는 도구가 있다. PUBLISH의 Message Expiry Interval(property 0x02)을 걸면 오래된 메시지는 broker가 버린다. Subscription option의 Retain Handling(§3.8.3.1)은 세 가지다. 0은 subscribe할 때 항상 retained를 보내고, 1은 그 subscription이 처음일 때만 보내고, 2는 아예 안 보낸다. Edge gateway 재접속에 command가 되살아나는 걸 막으려면 2가 맞다. Broker가 retained를 지원하는지는 CONNACK의 Retain Available(property 0x25)로 확인한다.

의견: status와 birth message는 retained가 맞다. Operator command는 retained 금지다. Application이 command ID와 만료를 직접 처리한다면 예외지만, 그렇게 만들어진 현장은 드물다.

거부된 publish는 아무 소리도 안 낸다

이게 edge에서 MQTT 5.0을 쓰는 가장 강한 이유다.

MQTT 3.1.1의 PUBACK은 variable header가 Packet Identifier 2 byte뿐이다 (§3.4). 실패를 담을 자리가 아예 없다. Broker가 ACL로 publish를 거부해도 publisher에게 알릴 방법이 없어서, 조용히 버리거나 연결을 닫는다. QoS 0이면 흔적도 안 남는다. 운전자는 stale data만 보고 이유를 모른다.

Subscribe는 그나마 보인다. 3.1.1 SUBACK의 return code에 0x80 (Failure)이 있다 (§3.9.3). 다만 하나로 뭉쳐 있어서 권한 문제인지 topic filter 문제인지 구분이 안 된다.

MQTT 5.0이 이 부분을 고쳤다.

PacketReason code뜻
CONNACK0x87Not authorized
CONNACK0x85Client Identifier not valid
PUBACK / PUBREC0x87Not authorized
SUBACK0x87Not authorized
SUBACK0x8FTopic Filter invalid
DISCONNECT0x8ESession taken over

Client가 이유를 자기 log에 남길 수 있다는 게 차이다. 3.1.1에서는 이 정보가 broker log에만 있고, broker log는 아무도 안 본다.

Broker ACL denial log는 driver error나 OPC UA connection error와 같은 운영 log review 절차에 넣어라. Platform이 지원하면 diagnostic tag나 health 화면도 만든다.

Credential은 역할마다 나눈다

공유 credential은 ACL을 무의미하게 만든다. IEC 62443-3-3 SR 1.2는 사람이 아닌 software process와 device도 각각 식별하고 인증하라고 요구한다. SR 2.1은 그 identity에 authorization을 enforce하라고 한다. SCADA server, historian, MES connector가 username 하나를 공유하면 둘 다 못 맞춘다. Broker가 권한을 나눌 수 없고, 잘못된 publish의 출처도 좁힐 수 없다.

Credential이 어디에 저장되는지도 프로젝트 노트에 남긴다. Service account, secret store, environment variable, container secret, gateway 설정 중 어디인지 알아야 비밀번호 교체 때 헤매지 않는다. 교체 시험은 maintenance window에서 reconnect와 session 재사용 동작까지 본다. MQTT 5.0이면 Session Expiry Interval(property 0x11)이 걸려 있는지도 같이 본다.

Go-live 전 시험 항목

ACL 시험은 broker 설치 작업이 아니라 시운전 항목이다. 기대 결과까지 적어 놓고 확인한다.

  1. 운영 client를 실제 credential과 client ID로 접속한다. CONNACK return code 0을 확인한다.
  2. 허용된 subscription에서 기대한 data가 들어오는지 본다.
  3. Client area 밖의 금지 subscription을 하나 시도한다. SUBACK 0x80(3.1.1) 또는 0x87(5.0)이 와야 한다.
  4. Command나 telemetry topic으로 금지 publish를 하나 시도한다. 5.0이면 PUBACK 0x87, 3.1.1이면 client 쪽에는 아무 것도 안 온다. Broker log에서 확인한다.
  5. Broker log에 denial 이유가 추적 가능한 수준으로 남는지 본다.
  6. 안전한 시간대에 duplicate client ID로 접속해 본다. 기존 연결이 끊기는 것을 확인한다.
  7. Client를 정상 종료하지 말고 network를 끊는다. Will publish까지 걸린 초를 기록한다. Keep Alive의 1.5배 안이어야 한다.
  8. Status와 command topic의 retained 동작을 따로 본다.

결과는 역할별로 남긴다. 어떤 credential로, 어떤 topic을 시도했고, broker가 뭘 돌려줬고, SCADA 화면에는 어떻게 보였는지까지 적어야 나중에 쓸 수 있다.

인수인계 문서에 들어갈 것

  • Client ID naming rule, 배정된 ID, 그리고 23 byte 한계선에 대한 판단.
  • Topic namespace와 publish / subscribe 방향.
  • ACL rule 또는 broker policy export.
  • Credential owner와 rotation 절차.
  • Topic group별 retained 정책과 Message Expiry Interval 값.
  • Edge gateway별 Last Will topic, payload, 그리고 측정한 Will 지연 시간.
  • Denied publish, denied subscribe, duplicate client ID, authentication failure broker log 예시.

다음에 확인할 것: 지금 broker가 MQTT 3.1.1인지 5.0인지, 그리고 edge client library가 5.0 reason code를 실제로 log에 찍는지. Library가 버리면 broker가 5.0이어도 얻는 게 없다.