본문으로 건너뛰기

레거시·상용 스키드 연결: Modbus, Siemens S7, PLC4X

📍 현재 위치: 제2부 · 공정 포착하기 — 제11장. 7–9장에서 다룬 깔끔한 OPC UA 바이오리액터(bioreactor)는 규칙이 아니라 예외입니다. 이 장은 현장에 놓인 다른 장비들 — 수확 원심분리기, TFF 스키드, 저울 — 에 손을 뻗습니다. 이들은 더 낡고 안전하지 않은 프로토콜로 말하며, 우리는 OT(운영 기술, operational-technology) 분할(segmentation) 뒤에서 이들을 안전하게 읽어 동일한 태그 네임스페이스(tag namespace, 5장에서 다룬 플랫폼 전체가 공유하는 단일 표준 이름 트리)로 들여옵니다.

쉽게 말하면

OPC UA는 정중하고 스스로를 설명하는 프로토콜입니다. 서버에 "무엇을 가지고 있나요?"라고 물으면, 이름과 단위와 보안과 함께 답해 줍니다. Modbus는 그 반대입니다. Modbus 장치는 16비트짜리 비둘기집 칸들에 번호를 매긴 찬장입니다. 1번 칸에는 1850이 들어 있습니다. 그것이 장치가 알려 주는 전부입니다. 1850이 1.850 bar를 뜻하는지, 1850 밀리바를 뜻하는지, 아니면 무언가의 18.50을 뜻하는지는 전선 위 어디에도 적혀 있지 않습니다. 그것은 누군가의 책상 위 PDF 데이터시트(datasheet)에 살고 있습니다. 찬장에는 비밀번호가 없고 문에는 자물쇠가 없습니다. 그래서 우리는 두 가지를 합니다. 찬장을 경비가 지키는 방 안에 넣고(네트워크 분할), 우리만의 라벨이 붙은 열쇠고리(태그 사전)를 따로 지닙니다. 이 열쇠고리에는 "1번 칸, 0.001을 곱하고, 단위는 bar인 막간 압력(transmembrane pressure)이라 부른다"라고 적혀 있습니다(막간 압력은 여과 막을 가로지르는 압력 강하로, 여과 스키드가 측정하는 평범한 값입니다). 이 장은 그 열쇠고리를 실제로 실행 가능한 코드로 만듭니다.

이 장에서 다루는 내용

이제 우리는 현대적인 바이오리액터를 깔끔하게 포착할 수 있습니다. 하지만 실제 바이오 제조 현장을 걸어 보면, 대부분의 장비는 OPC UA보다 더 오래되었습니다. 2009년에 설치된 원심분리기, "사이버보안(cybersecurity)"이 조달 항목이 되기도 전에 사양이 정해진 PLC(programmable logic controller, 장비를 구동하는 산업용 컴퓨터)가 들어간 접선 흐름 여과(tangential-flow-filtration, TFF) 스키드(펌프, 밸브, 센서, 그리고 PLC가 하나의 프레임에 장착된 패키지화된 공정 유닛), 시리얼 포트가 달린 실험대용 저울 등이 그렇습니다. 이들은 ModbusSiemens S7으로 말합니다. 신뢰할 수 있는 전선(모든 장치가 우호적이라고 가정한, 사설로 물리적으로 격리된 네트워크)을 전제로 설계된, 인증도 없고 암호화도 없는 프로토콜입니다.

우리는 다음을 하게 됩니다.

  • PyModbus로 (모의) TFF 스키드를 Modbus TCP로 읽고, 그 원시(raw) 정수 레지스터를 플랫폼의 나머지 부분이 쓰는 것과 동일한 공학 단위(engineering-unit) 태그로 스케일링(scaling)합니다.
  • Modbus와 S7이 보안을 전혀 담고 있지 않다는 냉혹한 진실, 그리고 Siemens S7-1200/1500의 PUT/GET 단서가 실제로 무엇을 의미하는지를 마주합니다.
  • OT 네트워크 분할(network segmentation)을, 우리가 이 장치들에 손을 댈 수 있게 해 주는 보완 통제(compensating control)로 다루고, 그것을 위험 기반(risk-based) 결정으로 기록합니다.
  • 그리고 python-snap7Apache PLC4X가 동일한 패턴을 Siemens PLC와 혼합 레거시 장비군으로 어떻게 확장하는지 살펴봅니다.

이 장의 실행 가능한 코드는 examples/chapters/09-legacy-skids-modbus-s7/modbus_reader.py입니다. S7과 PLC4X 자료는 현실적이고 명확히 라벨이 붙은 구성(configuration)으로 제시됩니다. 노트북 안에 Siemens PLC가 들어 있는 것은 아니며, 우리는 시뮬레이션이 정확히 어디에서 멈추는지에 대해 솔직할 것입니다.

레거시 프로토콜이 다른 종류의 동물인 이유

Modbus는 1979년에 발표되어, 단순한 요청/응답, 클라이언트/서버 메시징 프로토콜로 표준화되었습니다. 클라이언트가 함수 코드(function code, 홀딩 레지스터 읽기, 코일 쓰기 — 코일은 단일 켜짐/꺼짐 비트)와 주소를 보내면, 서버가 16비트 레지스터 값이나 단일 비트 코일로 응답합니다 [1]. 프레임 안 어디에도 사용자 이름, 비밀번호, 서명, 세션 키를 담을 자리가 없습니다. 전혀 없습니다. 전선 위의 장치는 어떤 클라이언트가 요청하든 그대로 따릅니다. 이것은 패치할 수 있는 버그가 아니라 — 프로토콜 그 자체입니다.

Modbus 단체는 결국 이를 인정하고, 프로토콜을 X.509 클라이언트 인증서를 갖춘 TLS로 감싸는 Modbus/TCP Security 사양을 발표했습니다. 하지만 그것은 나중에 나온, 선택적인 변형이며, 이 장에서 우리가 손을 뻗는 스키드들은 그보다 앞서 만들어졌고 결코 그것을 지원하지 않을 것입니다 [2]. 그래서 정직한 엔지니어링 입장은 이렇습니다. 우리는 프로토콜을 안전하게 만들 수 없으므로, 그것을 둘러싼 네트워크를 안전하게 만들고, 장치 자체가 담지 못하는 의미를 소유하는 검증된 엣지(edge) 계층을 통해 장치를 읽습니다.

Siemens S7comm(그리고 그 최신판인 S7CommPlus)도 다른 방언으로 같은 이야기를 합니다. 그것은 TCP 위에서 TPKT/COTP/S7comm 스택을 타고 다니며, Modbus와 마찬가지로 신뢰할 수 있는 자동화 네트워크를 전제로 만들어졌습니다. 보안 연구자들은 S7CommPlus의 무결성(integrity) 메커니즘이 재전송(replay) 및 주입(injection) 공격으로 무력화될 수 있음을 입증했습니다. 그 "재전송 방지(anti-replay)"는 견고한 인증이 아닙니다 [9]. 데이터 엔지니어에게 실질적인 교훈은 Modbus와 동일합니다. 프로토콜이 스스로를 보호하리라 믿지 말고, 분할하고, 통제된 계층 아래에서 읽어라.

라벨이 붙은 세 개의 존으로 이루어진 흐름도. OT 존에는 두 장치가 있다 — Modbus TCP로 인증 없이 통신하는 TFF01 스키드 PLC와 Siemens S7-1500을 쓰는 원심분리기다. 둘은 modbus_reader.py, snap7, 또는 PLC4X를 실행하는 검증된 엣지 계층으로 모인다. 스키드에서 나오는 화살표에는 홀딩 레지스터 원시 정수가, 원심분리기에서 나오는 화살표에는 S7 데이터 블록이 라벨로 붙어 있다. gov.tag_dictionary 박스가 단위와 UNS 이름을 공급하며 리더로 이어진다. 리더는 스케일링되고 이름과 단위가 찍힌 태그 화살표를 플랫폼 박스 ts.sensor_reading으로 내보낸다.

Modbus 스키드를 실제로 읽기

여기 레거시 PLC가 내놓는 그대로의 장치가 있습니다. examples/chapters/09-legacy-skids-modbus-s7/modbus_reader.py에서 가져왔습니다.

# raw holding registers the skid PLC exposes (scaled integers, as legacy PLCs do).
# Modbus holding registers are conventionally numbered 40001.., here at addresses 1-4.
TFF_RAW = [1850, 320, 1240, 78] # TMP, flux, conductivity, recovery
# position -> (tag, scale, unit) — the normalization the edge gateway applies
SCALING = [
("TFF01.TMP.PV", 0.001, "bar"), # 1850 -> 1.850 bar
("TFF01.Flux.PV", 0.1, "LMH"), # 320 -> 32.0 LMH
("TFF01.Cond.PV", 0.01, "mS/cm"), # 1240 -> 12.40 mS/cm
("TFF01.Recovery.PV", 1.0, "%"), # 78 -> 78 %
]

TFF_RAW를 잠깐 응시해 보세요. 이것이 네 개의 정수 안에 담긴 문제 전체이기 때문입니다. 장치에게 1850은 1.850 bar가 아닙니다. 그것은 그저 숫자 1850일 뿐입니다. 레거시 PLC는 부동소수점(floating-point) 공학 단위를 저장하는 일이 거의 없습니다. 메모리와 필드버스(fieldbus) 대역폭이 귀했기 때문에, 값은 스케일링된 정수(scaled integer)로 저장됩니다. 압력은 ×1000, 플럭스(flux)는 ×10, 전도도(conductivity)는 ×100입니다. 스케일 인수(scale factor)는 레지스터 맵(register map)에서 합의된 관례이지, 프로토콜이 알려 주는 무언가가 아닙니다. 만약 SCALING 표가 소수점 한 자리만큼 틀리면, 막간 압력은 1.85 대신 18.5 bar로 읽히고, 전선 위 그 무엇도 그 사실을 알려 주지 않습니다.

SCALING 표가 이 장의 조용한 영웅입니다. 그것은 원시 레지스터에서 5장에서 설계한 UNS(Unified Namespace, 통합 네임스페이스) 태그 네임스페이스 — 플랫폼 전체가 공유하는 그 하나의 표준 이름 트리 — 로 이어지는 다리입니다. TFF01.TMP.PV는 접선 흐름 여과 스키드의 막간 압력 공정값(process value, PV는 실시간으로 측정된 값)을 bar 단위로 나타내는 표준(canonical) 이름입니다. 레거시 장치는 그중 무엇도 알지 못합니다. 엣지 계층이 그것을 공급합니다. 운영 환경에서 gov.tag_dictionary는 — 5장에서 생성되고 린트(lint)되며 — 표준 단위와 UNS 이름을 공급하고, 스케일 인수는 검토된 엣지 구성입니다. 어느 쪽이든 그 매핑은 스크립트에 묻힌 마법의 숫자가 아니라 검토된 구성입니다.

스케일링 자체는 여섯 줄짜리 함수입니다. 의도적으로 우직하게, 테스트하기 쉽고, 검증하기 쉽게 만들었습니다.

def scale(registers: list[int]) -> dict[str, dict]:
out = {}
for value, (tag, factor, unit) in zip(registers, SCALING):
out[tag] = {"value": round(value * factor, 3), "unit": unit}
return out

그리고 여기, 실제 장치와 대화하는 부분이 있습니다. 엣지 수집기(collector)가 호출하는 실제 PyModbus 클라이언트 호출입니다. PyModbus는 TCP와 시리얼 RTU/ASCII 양쪽을 위한 완전한 오픈소스 Modbus 클라이언트/서버이며, 바로 그렇기 때문에 검증된 엣지 계층에서 이런 스키드에 손을 뻗을 때 선택되는 도구입니다 [3].

async def read_skid(host: str = "127.0.0.1", port: int = 502, unit: int = 1) -> dict:
"""Read a real TFF skid over Modbus TCP and normalize to engineering units.

This is the actual pymodbus client call an edge collector makes; point it at
a real skid by host/port. Holding registers are read
starting at address 0 (40001) — confirm your device's base with its map.
"""
from pymodbus.client import AsyncModbusTcpClient

client = AsyncModbusTcpClient(host, port=port)
await client.connect()
rr = await client.read_holding_registers(address=0, count=len(TFF_RAW), device_id=unit)
client.close()
if rr.isError():
raise OSError(f"Modbus read failed: {rr}")
return scale(list(rr.registers))

그 짧은 함수 안의 세 가지 세부 사항은 주목할 가치가 있습니다. 각각이 고전적인 레거시 통합의 함정이기 때문입니다.

address=0은 Modbus 세계의 그 악명 높은 하나 차이(off-by-one) 문제입니다. 홀딩 레지스터는 40001, 40002, 40003…으로 문서화되어 있지만, 실제 전선 위 프로토콜 주소는 0부터 시작합니다. 그래서 "40001" 레지스터는 주소 0에서 읽힙니다. 데이터시트가 문서 번호를 뜻하는지 전선 번호를 뜻하는지에 대해 벤더마다 의견이 갈리므로, 코드 안의 주석 — confirm your device's base with its map — 은 형식적인 문구가 아닙니다. 첫 읽기가 쓰레기 값을 돌려주는 가장 흔한 단 하나의 이유입니다.

device_id=unit(Modbus "유닛 ID" 또는 슬레이브 주소)이 중요한 이유는, 하나의 Modbus/TCP 게이트웨이가 뒤에 데이지 체인(daisy-chain)으로 연결된 여러 시리얼 장치를 종종 앞단에서 대표하기 때문입니다. 유닛 ID가 어느 물리적 상자가 응답할지 고릅니다. 그리고 명시적인 rr.isError() 검사가 존재하는 이유는, Modbus에는 OPC UA처럼 품질 플래그(quality flag)가 없기 때문입니다(9장의 Good/Uncertain/Bad 상태 코드를 떠올려 보세요). Modbus 읽기는 숫자를 돌려주거나 예외 응답을 돌려줄 뿐이며, 그것을 히스토리안(historian, 모든 읽기값의 장기 보관처인 시계열 데이터베이스)이 믿을 수 있는 무언가로 바꾸는 일은 우리 — 엣지 계층 — 의 몫입니다. 데이터 품질에 대한 정직함은 우리가 더해야 하는 것입니다. 프로토콜은 해 주지 않습니다.

Modbus 읽기의 해부학: 프레임을 필드 단위로 (그리고 비어 있는 칸들)

read_skid()가 수행하는 단 하나의 트랜잭션을 천천히 해부해 볼 가치가 있습니다. 프로토콜의 모든 약점이 바이트 그 자체에 드러나 있기 때문입니다. Modbus/TCP 요청은 두 부분으로 이루어집니다. 7바이트짜리 MBAP 헤더(Modbus Application Protocol 헤더)와, 실제 함수와 주소를 실어 나르는 PDU(protocol data unit)입니다 [1]. MBAP 헤더는 트랜잭션 ID(클라이언트가 응답을 요청과 짝지을 수 있도록), 평범한 Modbus라면 항상 0x0000인 프로토콜 ID, 길이(Length), 그리고 유닛 ID를 담습니다 — 이 유닛 ID가 바로 우리 호출의 device_id=1입니다. 이어서 PDU는 함수 코드 0x03(홀딩 레지스터 읽기), 시작 주소, 그리고 수량을 실어 나릅니다. 응답은 함수 코드를 되돌려 주고, 바이트 수를 명시하며, 원시 16비트 레지스터 워드를 돌려줍니다 — 그것이 대화의 전부입니다.

아래 그림은 그 트랜잭션을 필드 단위로 펼쳐 놓습니다. 가장 시사적인 부분은 존재하는 필드가 아니라 부재하는 필드입니다. 장미색(rose) 띠는 Modbus 프레임이 도무지 자리를 내줄 수 없는 칸들을 열거합니다. 그것을 7장의 OPC UA DataValue 해부도 — 신원, 타입, 값, 품질, 출처 타임스탬프, 공학 단위를 자기 기술적(self-describing)인 하나의 구조에 담았던 — 와 나란히 놓으면, 그 대비가 이 장의 전부입니다. OPC UA가 의미를 자발적으로 내놓는 자리에서, Modbus는 네 개의 정수를 내놓고 나머지 모든 칸은 엣지 계층이 채우도록 비워 둡니다.

하나의 Modbus TCP 읽기 트랜잭션을 해부하는 신원 카드. 요청 프레임이 MBAP 헤더 필드(Transaction ID 0x0001, Protocol ID 0x0000, Length 0x0006, Unit ID 0x01)와 PDU 필드(함수 코드 0x03, 수량 0x0004)로 분해되어 있고, 시작 주소는 0 대 40001의 하나 차이로 강조되어 있다. 응답은 네 개의 원시 16비트 레지스터 워드 1850, 320, 1240, 78을 맨 정수로 보여 주며, 장미색 띠는 프레임이 담지 못하는 필드들을 열거한다 — 단위 없음, 타임스탬프 없음, 품질 플래그 없음, 인증·서명 없음, 스케일·레지스터 맵 없음. Modbus의 모든 약점은 프레임에 드러나 있다: 맨 정수 네 개가 돌아오고, 단위·타임스탬프·품질·인증·스케일 인수는 모두 검증된 엣지 계층이 공급해야 하는 빈 칸이다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

그 그림에서 두 필드는 더 자세히 볼 가치가 있습니다. 프로토콜 ID0x0000으로 못박혀 있습니다. 버전 협상도, 능력 교환도, 보안 핸드셰이크를 실을 수 있는 그 무엇도 없습니다 — 언젠가 그런 것으로 자랄 수도 있었던 필드가 영구히 봉인된 셈입니다. 그리고 시작 주소는 하나 차이(off-by-one)를 구체화합니다. 전선 위 PDU는 0x0000이라고 말하지만, 장치의 데이터시트는 바로 그 레지스터를 40001이라고 부릅니다. "4xxxx" 번호가 1을 뺀 채 전선 주소의 홀딩 레지스터에 매핑된다는 관례는 수십 년 묵었으나 보편적으로 지켜지지는 않습니다. 그래서 똑같은 물리적 값이 어느 벤더의 도구에서는 주소 0에서, 다른 벤더의 도구에서는 주소 1에서 읽힙니다. 프레임 안의 그 무엇도 그것을 가려 주지 않습니다. 오직 누군가의 책상 위 레지스터 맵만이 가려 줍니다.

읽기의 생애 주기: 연결하고, 유닛 ID로 라우팅하고, 스케일링한다

해부도는 시간 속에 얼어붙은 프레임을 보여 주고, 아래 시퀀스는 그것이 움직이는 모습을 보여 줍니다. 단 한 번의 read_skid() 호출은 경비가 지키는 경계를 가로지르는 5단계 왕복이며, 각 단계는 프로토콜이 보장하는 것이 아니라 엣지 계층이 소유하는 것입니다.

세 개의 라이프라인에 걸친 Modbus 읽기 생애 주기의 시퀀스 다이어그램: modbus_reader.py를 실행하는 엣지 수집기, Modbus TCP 게이트웨이, 그리고 유닛 ID 1의 TFF01 스키드 PLC. 1단계는 TCP 502 포트를 연다. 2단계는 read_holding_registers 주소 0 카운트 4 device_id 1을 보내고, 게이트웨이는 이를 유닛 ID로 물리적 상자에 라우팅한다. 3단계는 단위·타임스탬프·품질 없이 원시 워드 1850, 320, 1240, 78을 돌려준다. 4단계는 rr.isError 검사다. 5단계는 스케일링 표를 적용하여 이름과 단위가 찍힌 태그 네 개를 ts.sensor_reading으로 내보내며 품질은 192 Good으로 기본 설정된다. 점선 수직 띠가 트래픽이 가로지르는 OT 분할 컨듀잇을 나타낸다.

그림에 번호를 맞춰 한 번 따라가 봅시다.

  1. 연결. await client.connect()는 502 포트(Modbus TCP에 예약된 잘 알려진 포트)에서 TCP 세션을 엽니다 — 그리고 결정적으로, 그것은 OT 분할 컨듀잇(점선 띠)을 가로지릅니다. 그 횡단이 수집기의 존과 스키드의 존 사이에 허용된 유일한 트래픽이며, 그것이 인증되지 않은 장치를 읽는 일을 정당화할 수 있게 해 줍니다.
  2. 읽기. 수집기는 address=0, count=4, device_id=1에 대해 함수 0x03을 보냅니다. 게이트웨이는 유닛 ID를 사용해 1에 응답하는 그 하나의 데이지 체인 시리얼 상자로 요청을 라우팅합니다.
  3. 응답. [1850, 320, 1240, 78]이 돌아옵니다 — 원시 워드 네 개, 그게 전부입니다. 단위도, 출처 타임스탬프도, 품질도 없습니다. 바로 이 순간이 해부도의 빈 칸들이 런타임 문제가 되는 지점입니다.
  4. 검사. rr.isError()는 Modbus가 제공하는 품질 신호의 전부입니다. 읽기가 성공했거나 예외 응답을 일으켰거나 둘 중 하나입니다. "Uncertain"은 없습니다. 그 이진(binary) 신호가 히스토리안에게 무엇을 뜻하는지는 엣지 계층이 결정해야 합니다.
  5. 스케일링과 내보내기. scale()SCALING 표를 적용하고, 이름과 단위가 찍힌 태그 네 개가 ts.sensor_reading(히스토리안의 읽기값 테이블)에 안착합니다 — TFF01.TMP.PV는 1.85 bar로, 이런 식입니다. quality 컬럼은 192로 기본 설정됩니다. 그 숫자는 OPC DA(Classic)의 Good 코드입니다 — 이 히스토리안이 물려받은 더 오래된 품질 체계(7장에서 확립된 192/64/0 관례)로, 192 = Good, 64 = Uncertain, 0 = Bad입니다(OPC UA의 더 새로운 체계는 대신 Good에 0을 씁니다). 전선이 품질을 보낸 적이 전혀 없으므로, 엣지 계층이 그것을 주장하는 것입니다. 그 주장 자체가 의식해 둘 가치가 있는 데이터 무결성 결정입니다.

Modbus 대 S7: 각 프로토콜이 자발적으로 내놓는 것, 나란히 비교

두 레거시 방언을 OPC UA 기준선과 나란히 줄 세워 보면 도움이 됩니다. 데이터 엔지니어가 보완해야 하는 일이 바로 컬럼 사이의 차이이기 때문입니다.

전선이 실어 나르는 것OPC UA (9장)ModbusSiemens S7comm
타입이 있는 DataValue원시 16비트 정수데이터 블록의 원시 바이트
공학 단위있음 (EUInformation)없음 — 레지스터 맵뿐없음 — DB 레이아웃뿐
출처 타임스탬프있음없음없음
품질 / 상태Good / Uncertain / Bad오류-인지-아닌지오류-인지-아닌지
신원 / 이름NodeId + BrowseName주소 번호DB 번호 + 오프셋
인증세션 + 인증서없음약함; 재전송으로 무력화 [9]
주소 함정네임스페이스 인덱스 (NodeId의 ns= 접두사)40001 대 전선 0최적화 대 표준 DB 접근

위에서 아래로 읽으면, 이 표는 작업 목록입니다. "없음"이나 "약함" 하나하나가 엣지 계층이 검토된 구성에서 채워야 하는 컬럼입니다 — SCALING 표, 단위 문자열, gov.tag_dictionary 이름, 분할 정당화 — 그리고 모든 레거시 프로토콜은 대체로 똑같은 칸들을 비워 둡니다. 바로 그래서 장치마다 맞춤형 통합이 아니라 하나의 정규화 패턴이 옳은 형태인 것입니다.

스케일링 표, 그래프가 검사할 수 있는 형상으로

그 작업 목록은 읽기값이 저장 계층에 닿는 순간 정확한 형태를 갖습니다. 이 엣지 계층이 주조하는 바로 그 표준 이름들 — TFF01.TMP.PV와 그 형제들 — 은 시맨틱과 디지털 스레드에서 플랫폼이 구축하는 지식그래프에 들어가는 순간 IRI(Internationalized Resource Identifier, 웹 스타일의 전역 이름)가 되고, SCALING 표가 공급하는 단위는 그 노드 위의 타입이 있는 사실이 됩니다. 스케일링된 읽기값 하나는, RDF(Resource Description Framework, 모든 사실을 주어–술어–목적어 트리플(triple)로 표현함)에서는 세 개의 트리플입니다.

# the reading the edge layer emits, expressed as RDF triples
bp:TFF01-TMP a bp:ProcessValue ;
qudt:hasUnit unit:BAR ; # the unit the SCALING table supplied, now machine-readable
bp:numericValue "1.85"^^xsd:float .

이것을 장식이 아니라 통제로 만드는 것은, 장미색 띠가 그렸던 바로 그 빈 칸 — 전선 위에 단위 없음 — 을 이제 SHACL 형상(Shapes Constraint Language, 형상에 대해 트리플을 검증하는 W3C 표준)으로 강제할 수 있다는 점입니다. 그래서 단위 없이 도착한 읽기값은 조용히 저장되는 대신 관문에서 거부됩니다.

# every ProcessValue MUST carry exactly one unit — the empty slot, enforced
bp:ProcessValueShape a sh:NodeShape ;
sh:targetClass bp:ProcessValue ;
sh:property [ sh:path qudt:hasUnit ; sh:minCount 1 ; sh:maxCount 1 ] .

이것이 이 장이 거듭 품는 걱정에 대한 형식적 답입니다. "모든 읽기값이 전선이 빠뜨린 단위와 표준 이름을 담고 있는가?"는 온톨로지가 반드시 답할 수 있어야 하는 종류의 역량 질문(competency question) — 4권이 쿼리로서의 역량 질문에서 구축하는 실행 가능한 PASS/FAIL 인수 시험 — 이며, qudt:hasUnit 타입 부여는 양의 단위를 문자열이 아니라 사실로 고정하는 식별자와 단위의 규율입니다. 이런 관점에서 gov.tag_dictionary는 그 공유 표준에 정렬된 작은 로컬 온톨로지입니다 — 클래스와 분류 체계의 의미에서 bp:ProcessValue라는 클래스, 인스턴스와 그래프의 의미에서 TFF01이라는 인스턴스 — 그래서 레거시 프로토콜이 떨어뜨리는 의미가, 사람이 PDF에 적어 둔 무언가가 아니라 기계가 검증할 수 있는 무언가로 복원됩니다.

신호가 실제로 오는 곳: PendoTECH 사례

스키드 PLC를 Modbus로 읽는 것은 TFF 수치를 얻는 한 가지 방법이지만 — 신호가 여러분에게 닿는 유일한 방법은 아닙니다. 실제 TFF 또는 UF/DF(ultrafiltration / diafiltration, 한외여과 / 정용여과) 스키드에서는, 압력과 막간 압력(transmembrane pressure, TMP) 채널이 흐름 경로 위의 일회용 인라인 압력 센서에 연결된 전용 단일 사용 센서 모니터(single-use sensor monitor)에서 오는 경우가 잦습니다. 이 센서들은 세 개의 TFF 압력 — 공급(feed, 필터로 밀어 넣는 흐름), 잔류(retentate, 막혀 되돌아 순환되는 것), 투과(permeate, 막을 통과하는 것) — 을 읽으며, TMP 자체는 그것들로부터 대략 (P_feed + P_retentate)/2 − P_permeate로 유도됩니다. 바로 그래서 원시 1850 정수조차 직접 측정된 값이 아니라 계산된 레지스터 값인 것입니다. 널리 쓰이는 대표적 예가 PendoTECH 단일 사용 센서 모니터입니다. 이런 모니터는 대개 두 개의 문을 내주며, 그것은 바로 이 장이 거듭 돌아오는 그 두 개의 문입니다. 레지스터 맵으로부터 Modbus로 실시간 읽거나, 아니면 그것이 기록하는 것을 끌어오거나입니다. 즉, 몇 초마다 구분된 텍스트 파일을 로컬 또는 네트워크 저장소에 기록하거나, 아니면 AVEVA PI 같은 상류 히스토리안(historian)이 직접 구독하는 OPC UA를 게시합니다.

함정은 그 기록된 파일 안에 있으며, 그것은 TFF_RAW와 똑같은 함정입니다. 네이티브 벤더 로그는 히스토리안이 원하는 깔끔한 long/tidy timestamp, tag, value 표가 아닙니다 — 그것은 여러 줄짜리 헤더 블록이 위에 덧붙은 이질적인(heterogeneous) CSV입니다. 읽기값이 시작되기 전에 계측기 메타데이터, 채널 이름, 단위, 보정 상수가 여러 줄에 걸쳐 들어가고, 컬럼 배치는 펌웨어 버전과 모니터 구성에 따라 달라집니다. 그래서 데이터가 Modbus가 아니라 파일로 도착하더라도, 엣지 계층의 일은 바뀌지 않습니다. 헤더 블록을 건너뛰어 파싱하고, 장치 네이티브 컬럼 각각을 표준 UNS 태그로 매핑하고, 파일이 명시한 단위를 붙이며(짐작하지 말고), 넓은(wide) 벤더 행을 scale()이 내보내는 것과 동일한 ASSET.Measurement.PV 기록으로 다시 빚어내야 합니다. 경로가 Modbus 읽기든, 벤더 파일이든, OPC UA 스트림이든, 장치가 빠뜨린 의미를 공급하는 일은 여전히 우리의 몫입니다.

하드웨어 없이 실행하기

노트북 안에 TFF 스키드가 없으므로, 이 파일은 동일한 스케일링을 알려진 레지스터 스냅샷에 적용하는 demo()를 함께 제공합니다. 그래서 이 장은 하드웨어 0, 네트워크 0으로 처음부터 끝까지 실행할 수 있습니다.

def demo() -> dict:
"""Apply the engineering-unit scaling to a known register snapshot (no network)."""
return scale(TFF_RAW)


if __name__ == "__main__":
for tag, v in demo().items():
print(f" {tag:20} = {v['value']} {v['unit']}")

실행해 봅시다.

$ python chapters/09-legacy-skids-modbus-s7/modbus_reader.py
TFF01.TMP.PV = 1.85 bar
TFF01.Flux.PV = 32.0 LMH
TFF01.Cond.PV = 12.4 mS/cm
TFF01.Recovery.PV = 78.0 %

그 네 줄이 이 장의 요점 전체를 구체적으로 보여 줍니다. 의미 없는 정수 네 개가 들어가서, 이름이 붙고, 스케일링되고, 단위가 찍힌 측정값 네 개가 나왔습니다. 그것도 플랫폼의 나머지 부분이 말하는 바로 그 ASSET.Measurement.PV 형태로 말입니다. 1.85 bar의 막간 압력과 32 LMH(제곱미터당 시간당 리터)의 플럭스는 mAb(monoclonal antibody, 단일클론항체) TFF 단계에 합당한 수치이며 — 플럭스는 막 면적당 투과 유량이고, 전도도는 정용여과(diafiltration)가 교환하고 있는 완충액/염 수준을 따라가며, 회수율은 보유된 산물의 비율입니다 — 78.0 %는 최종 단계 수율이 아니라 공정 도중에 읽힌 순간적인 공정값(process value)입니다(최종 수율이라면 보통 이보다 훨씬 높습니다). 그러니 이를 건강한 TFF 회수율 수치로 읽어서는 안 됩니다. 이제 그것들은 ts.sensor_reading으로 흘러들어 다른 어떤 OPC UA 태그와도 똑같이 배치(batch)와 페이즈(phase)에 맥락화(contextualize)될 수 있습니다. 이것이 무엇을 증명하는지에 대해서는 솔직해야 합니다. 이것은 특정 벤더의 Modbus 특이점이 아니라 통합 로직과 데이터 형태를 실행해 본 것입니다. read_skid() 경로가 실제 클라이언트 호출입니다. 호스트와 포트로 그것을 실제 스키드에 겨누면, 돌아오는 값에 대해 동일한 scale()이 실행됩니다.

레거시 TFF 스키드 PLC가 네 개의 원시 정수 홀딩 레지스터를 노출하고, 화살표가 그것들을 modbus_reader.py를 실행하는 엣지 수집기로 실어 나르며, 거기서 스케일링 표가 각 값에 인수를 곱하고 UNS 태그 이름과 단위를 붙여, 이름이 붙은 네 개의 공학 단위 측정값을 히스토리안으로 내보낸다. 레거시 측면 전체는 박스로 둘러싸인 분할된 OT 존 안에 자리한다.

번호 매겨진 비둘기집 칸에서 이름이 붙은 기록으로: 레거시 Modbus 스키드는 원시 스케일링 정수만을 실어 나르므로, 검증된 엣지 계층이 프로토콜이 빠뜨린 스케일 인수, 단위, 표준 태그 이름을 공급한다 — 그것도 모두 OT 분할 뒤에서. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

Siemens S7과 PUT/GET 함정

많은 상용 스키드는 Modbus가 아니라 Siemens S7 PLC 위에 만들어져 있습니다. 여기서 오픈소스로 들어가는 문은 python-snap7입니다. TPKT/COTP/S7comm/S7CommPlus 스택을 구현하고 S7-300/400/1200/1500 제어기를 네이티브로 읽을 수 있는 순수 파이썬 S7 라이브러리입니다 [4]. 패턴은 read_skid()를 그대로 따라갑니다. 연결하고, 데이터 블록의 한 덩어리를 읽은 다음, 바이트로 디코딩하여 태그로 스케일링합니다. 다음은 예시적인 코드 조각입니다. 노트북에 Siemens PLC가 없으므로, 이것은 테스트된 실행이 아니라 호출의 형태입니다.

# Illustrative — requires a real/simulated S7 PLC; not run on a laptop.
import snap7
from snap7.util import get_int

client = snap7.client.Client()
client.connect("192.0.2.50", rack=0, slot=1) # S7-1500: rack 0, slot 1
db = client.db_read(db_number=10, start=0, size=8) # read 8 bytes of DB10
tmp_raw = get_int(db, 0) # offset 0 -> TMP scaled int
client.disconnect()

하지만 S7 PLC를 무언가에 연결하기 전에 반드시 알아야 할, 구체적이고 악명 높은 함정이 하나 있습니다. 현대의 S7-1200과 S7-1500 제어기에서 snap7의 "최적화된(optimized)" 데이터 블록 접근은 PLC의 PUT/GET 통신(communication) 설정과, 데이터 블록이 TIA Portal에서 "optimized block access"로 표시되지 않는 것에 달려 있습니다. PUT/GET이 비활성화되어 있으면(안전을 위해 이 제품군에서는 기본적으로 꺼져 있습니다) 읽기가 아예 실패합니다. 반대로 자동화 엔지니어가 여러분의 수집기가 읽을 수 있도록 그것을 켜면, 그들은 방금 문을 하나 열어젖힌 것이며 — S7comm의 약한 인증과 결합되어 — 어떤 클라이언트든 PLC를 읽고 쓸 수 있게 됩니다 [9]. 그 체크박스 하나는 편의를 위한 토글이 아니라 문서화된 위험 기반 결정입니다. 히스토리안에 데이터를 공급하기 위해 그것을 켜는 일은, 변경 기록(change record)과 네트워크 분할 정당화에 마땅히 들어가야 할 종류의 절충(trade-off)이지, 현장에서 조용히 넘겨선 안 되는 일입니다.

S7 읽기의 해부학: TPKT/COTP/S7comm과 DB 오프셋

위 코드 조각의 S7 읽기는 Modbus와 같은 종류의 계층화된 프레임을 숨기고 있습니다. 단지 더 깊을 뿐입니다. python-snap7은 TCP 안에 중첩된 세 프로토콜의 스택으로 말합니다. TPKT(RFC 1006, 메시지를 프레이밍함), COTP(그것을 실어 나르는 ISO 연결 지향 전송), 그리고 그 위의 S7comm입니다. S7comm의 작업 요청은 ROSCTR(원격 운영 서비스 제어, 읽기의 경우 "job"), 함수(변수 읽기), 그리고 — 여러분이 실제로 고르는 부분 — 메모리 영역, 데이터 블록 번호, 바이트 단위 시작 오프셋, 그리고 크기를 지정합니다. client.db_read(db_number=10, start=0, size=8)에서 마지막 세 가지는 DB10, 바이트 0, 그리고 8바이트입니다. 그런 다음 get_int(db, 0)이 그 버퍼에서 첫 16비트 정수를 디코딩합니다.

Modbus와의 매핑은 거의 한 줄 한 줄 대응됩니다. Modbus 홀딩 레지스터 주소는 S7의 (DB 번호, 바이트 오프셋) 쌍이 되고, Modbus 유닛 ID는 S7의 (랙, 슬롯) — PLC 섀시 안 CPU 모듈의 물리적 위치로, S7-1500이라면 rack=0, slot=1 — 이 됩니다. 하나 차이 함정은 새 옷을 입고 다시 나타납니다. S7 오프셋은 바이트 단위이므로 "워드 2"에 있는 16비트 값은 바이트 오프셋 2에 살고, 프로그램이 DBW0이라 부르는 값은 get_int(db, 0)인 반면 DBW2get_int(db, 2)입니다 — 잘못된 오프셋에서 읽으면 인접한 두 값이 뒤섞인 것을 디코딩하게 됩니다. 그리고 Modbus와 꼭 마찬가지로, 프레임은 단위도, 출처 타임스탬프도, 품질 플래그도 실어 나르지 않습니다. 디코딩된 원시 정수는 여전히 맨 숫자이므로, 생애 주기 그림의 바로 그 scale()-과-이름 붙이기 단계가 그대로 적용됩니다. 위 해부 카드는 "유닛 ID"를 "(랙, 슬롯)"으로, "시작 주소 0x0000"을 "DB10, 바이트 0"으로 바꾸면 S7용으로 다시 그릴 수 있습니다 — 장미색 띠의 모든 빈 칸은 정확히 그대로 비어 있습니다.

혼합 장비군을 위한 하나의 라이브러리: Apache PLC4X

실제 공장은 단 하나의 프로토콜인 경우가 드뭅니다. 같은 수확 스위트(suite) 안에서 Modbus S7 Allen-Bradley(Rockwell의 주요 PLC 제품군)를 동시에 만나게 되며, 각각을 위해 맞춤형 클라이언트를 작성하는 것이야말로 통합 프로젝트가 썩어 가는 방식입니다. Apache PLC4X는 프로토콜별 드라이버 뒤에 하나의 공유된 API를 제공하므로, 동일한 연결 문자열-읽기 코드가 TCP 위의 Siemens S7이나 TCP/RTU/ASCII 위의 Modbus 장치에 닿되, 애플리케이션은 어느 쪽인지 신경 쓰지 않아도 됩니다 [5]. Modbus 드라이버는 그 공통 API 아래에서 코일, 디스크리트 입력, 홀딩 레지스터, 입력 레지스터에 주소를 부여하고, S7 드라이버는 S7-300/400/1200/1500 계열과 대화합니다 [6].

실제로는 장비군을 구성(configuration)으로 표현하게 됩니다. 아래 블록은 우리의 두 레거시 자산을 위한 예시적인 PLC4X 스타일 연결 맵입니다. 엣지 서비스가 소비할 법한 edge/plc4x/plc4x-connect.yaml 종류이며, 이 장 디렉터리 안의 테스트된 산출물은 아닙니다.

# Illustrative PLC4X connection map (not a tested artifact in this chapter dir).
connections:
tff01:
url: "modbus-tcp://10.20.0.11:502?unit-identifier=1"
poll_ms: 1000
tags:
TFF01.TMP.PV: { address: "holding-register:1:INT", scale: 0.001, unit: "bar" }
TFF01.Flux.PV: { address: "holding-register:2:INT", scale: 0.1, unit: "LMH" }
centrifuge01:
url: "s7://10.20.0.21?remote-rack=0&remote-slot=1"
poll_ms: 2000
tags:
CFG01.Speed.PV: { address: "%DB10.DBW0:INT", scale: 1.0, unit: "rpm" }

scaleunit 키가 이제 프로토콜마다 다시 등장하는 점에 주목하세요. 레거시의 의미 문제는 결코 사라지지 않습니다. PLC4X는 단지 열쇠고리를 보관할 일관된 장소 하나를 줄 뿐입니다. 또한 PLC4X의 Modbus 드라이버가 1부터 시작하는 레지스터 번호를 쓴다는 점에도 주목하세요. 그래서 여기 holding-register:1은 우리의 원시 PyModbus 호출이 전선 address=0에서 닿는 바로 그 동일한 물리적 레지스터를 가리킵니다 — 해부 절의 40001 대 0 하나 차이가 이제 버그가 아니라 도구 관례의 차이로서 세 번째로 떠오르는 셈이며, 바로 그래서 주소 기준을 도구마다 못박아 두어야 합니다. 정직한 절충은 이렇습니다. PLC4X는 강력하고 Apache 라이선스인 Java/Go 프로젝트이지만, 60줄짜리 PyModbus 스크립트보다 무겁고, 그 프로토콜 드라이버의 성숙도는 장치마다 다릅니다. Modbus 스키드 하나라면 PyModbus가 옳습니다. 하나의 엣지 서비스에 데이터를 공급하는 혼합 레거시 제어기 장비군이라면, PLC4X가 그 무게값을 합니다.

왜 중요한가

레거시 통합은 데이터 무결성에 대한 야심이 실제 현장과 만나는 지점입니다. 우리가 옹호해 온 모든 ALCOA+ 속성 — 규제 기록이 갖춰야 하는 데이터 무결성 원칙으로, 귀속 가능(Attributable), 판독 가능(Legible), 동시 기록(Contemporaneous), 원본(Original), 정확(Accurate)이며 "+"는 완전(Complete), 일관(Consistent), 영속(Enduring), 가용(Available)을 더합니다 — 은 그중 무엇도 자발적으로 제공하지 않는 프로토콜에서 살아남아야 합니다. Modbus는 단위를 알려 주지 않고, 값을 출처에서 타임스탬프 찍지 않으며, 불량 데이터를 표시하지 않습니다. 엣지 계층이 1850을 잘못 스케일링하면, 히스토리안은 정확해 보이지만 잘못된 막간 압력을 충실하게 그리고 영구히 기록하며, 하류의 공정 검증(process-validation) 검토자는 그 오류를 볼 방법이 없습니다. 따라서 스케일링 표는 배관이 아닙니다. 그것은 데이터 무결성 통제이며, 다른 어떤 GMP(Good Manufacturing Practice, 우수 제조 관리 기준) 통제가 받는 검토, 버전 관리, 적격성 평가(qualification, 의도대로 작동함을 입증하는 문서화된 증거)를 똑같이 받아야 마땅합니다.

또한 중요한 이유는, 이 장비를 대개 통째로 뜯어내 교체할 수 없기 때문입니다. 적격성이 평가된 TFF 스키드나 원심분리기는 수년간의 검증을 대표합니다. "OPC UA 네이티브 장비를 사면 되지"는 기존 라인에 대해 좀처럼 현실적인 선택지가 아닙니다. 그래서 데이터 엔지니어의 일은 레거시 프로토콜이 사라지기를 바라는 것이 아니라 — 그것을 정직하게 읽고, 프로토콜이 결여한 통제로 감싸는 것입니다.

그리고 스케일링 표는 비유가 아니라 형식적인 규제적 의미에서 데이터 무결성 통제입니다. 전선이 한 번도 품질을 부여하지 않은 값에 quality=192를 주장하는 것, 그리고 프로토콜이 빠뜨린 단위와 표준 이름을 복원하는 것은, 바로 21 CFR Part 11 §11.10(a)가 "정확성, 신뢰성, 일관된 의도된 성능을 보장하도록" 검증되기를 요구하는 종류의 시스템 동작이며, EU Annex 11이 위험 평가되고 변경 통제되기를 기대하는 동작입니다 — 이 책이 Part 11 / Annex 11과 오픈소스에서 감사 추적과 서명에 대해 조항 단위로 다루는 바로 그것입니다. 따라서 SCALING 표는 변경 통제 아래에 놓이고, 그 스케일 인수는 다른 어떤 GMP 기능과 마찬가지로 IQ/OQ/PQ(설치/운영/성능 적격성 평가)로 적격성이 평가되며, 아래에서 이미 인용한 FDA의 CSA 위험 기반 자세가 그것을 얼마나 엄격히 테스트할지를 알려 줍니다. CQA에 데이터를 공급하는 레지스터는 스크립트화된 검증을 받고, 상태 플래그는 더 가벼운 점검을 받습니다.

포착된 스트림은 모든 하류 모델의 연료이기도 하다

명명할 가치가 있는 전방 의존성이 하나 있습니다. 이 장의 조용한 정확성 결정들이 머신러닝과 AI 책으로 전파되기 때문입니다. 소프트 센서, MSPC 모니터, 또는 표류 탐지기는 그것이 학습하는 맥락화된 태그만큼만 신뢰할 수 있으며 — 잘못 스케일링된 레지스터는 모델에게 보이는 오류가 아니라, 모델이 조용히 외삽해 통과하는 공변량 이동(covariate shift)입니다. 이 장의 습관 셋이 그 하류 작업에 핵심적입니다. 첫째, 배치로의 맥락화(읽기값을 batch_id 아래 ts.sensor_reading에 안착시키는 바로 그 조인)는 누설 없는 배치 그룹 분할을 애초에 가능하게 합니다 — 모델과 검증이 고집하듯, 배치가 증거의 단위일 때 유일하게 정직한 검증인 scikit-learn의 GroupKFold / LeaveOneGroupOutbatch_id로 그룹화하는 것입니다. 둘째, SCALING 인수가 함의하는 검증된 운전 범위는 모델의 적용 영역(applicability domain) — 모델이 보정된 입력 영역으로, 그 밖에서는 답하기를 거부해야 하는 영역 — 의 씨앗입니다. 18.5 bar로 잘못 스케일링된 1850은 센서를 자신이 적합된 어떤 영역에서도 한참 벗어난 곳에 떨어뜨리며, 모델은 그것을 알 길이 없습니다. 셋째, 순간적인 78.0 % 읽기값과 최종 수율을 구분하는 이 장의 분별은, MLOps와 생애 주기에서 공정 표류(살아 있는 시스템의 실제 변화)를 모델 표류(센서가 낡아 가는 것)와 구분하기 어렵게 만드는 바로 그 함정입니다. 히스토리안 스트림 위의 잔차 관리도는, 그것이 지켜보는 스트림이 스케일링되고, 단위가 찍히고, 그 배치까지 추적 가능할 때에만 둘을 분리할 수 있습니다. 쓰레기 태그는 큰 소리로 실패하는 모델을 만들지 않습니다. 그것은 확신에 차서, 보이지 않게 틀린 모델을 만듭니다 — 바로 그래서 이 장이 구축하는 화려하지 않은 엣지 계층의 정확성은 데이터 무결성 통제인 만큼이나 모델 품질 통제이기도 한 것입니다.

실제 현장에서는

노출은 가설이 아니다: 공개 인터넷 위의 레거시 OT

"Modbus에는 인증이 없다"를 이론적인 우려로 치부하기 쉽습니다 — 설마 이런 장치를 정말로 닿을 수 있게 내버려 두겠어? 그렇게 합니다, 그것도 대규모로요. Mirian과 동료들의 동료 심사를 거친 인터넷 전수 측정 연구는 ZMap 스캐너를 다섯 개의 SCADA(supervisory control and data acquisition, 감시 제어 및 데이터 수집) 프로토콜을 말하도록 확장하여 공개 IPv4 주소 공간 전체를 훑었습니다. 진짜 애플리케이션 계층 응답자로 걸러낸 뒤, 그들은 502 포트에서 응답하는 실제 Modbus 장치 약 23,000개와, 75개국에 걸친 Siemens S7 제어기 약 2,800개를, 총 6만 개가 넘는 공개적으로 접근 가능한 ICS(industrial control systems, 산업 제어 시스템) 가운데서 발견했습니다 [11]. 이것들은 집계에서 걸러낸 허니팟(honeypot)이 아닙니다. 바로 이 장이 읽는 그 제품군의, 생산급 제어기들이, 그것과 지구상 어떤 클라이언트 사이에 비밀번호 하나 없이 공개 인터넷에 앉아 있는 것입니다.

그 수치가 이 장이 주장하는 모든 것의 경험적 근거입니다. 해부 카드에서 보았듯 Modbus 프레임은 인증 필드를 전혀 실어 나르지 않으며 — 자격 증명을 담을 칸이 말 그대로 없습니다 [1] — S7comm의 무결성 메커니즘은 실험실에서 재전송과 주입으로 무력화되었습니다 [9]. 둘을 합치면, 직접 노출된 스키드는 단지 공격자가 읽을 수 있는 것에 그치지 않습니다. 쓰기 가능한 레지스터나 켜진 PUT/GET이 있으면 그것은 제어 가능합니다. 23,000이라는 숫자가 바로 이 장의 보완 통제가 프로토콜 수정이 아니라 분할인 이유입니다. 자격 증명을 담을 필드가 없는 프레임에 비밀번호를 더할 수는 없으므로, 정직한 유일한 완화책은 우리의 read_skid()가 가로지르는 그 하나의 문서화된 컨듀잇을 통하지 않고서는 장치에 결코 닿을 수 없도록 보장하는 것뿐입니다. 그 규율이 무너질 때 장치들이 정확히 있어서는 안 될 곳에 가 있게 된다는 것 — 스캔 연구는 그 현장 증거입니다.

냉정한 현실은, 안전하지 않은 레거시 프로토콜이 제약 업계 도처에 있다는 것, 그리고 규제 및 보안 프레임워크가 이미 여러분이 네트워크 계층에서 보완하기를 기대하고 있다는 것입니다. 권위 있는 OT 보안 지침인 NIST SP 800-82 Rev. 3은 바로 이것을 중심으로 세워져 있습니다. 존-앤-컨듀잇(zone-and-conduit) 아키텍처, 네트워크 분할, 그리고 스스로를 방어할 수 없는 프로토콜을 위한 보완 통제입니다 [7]. IEC 62443-3-3은 그것을 조언이 아니라 요구사항으로 만듭니다. 그 기본 요구사항 FR5(Restricted Data Flow, 제한된 데이터 흐름), 그중 SR 5.1 Network Segmentation은, 안전하지 않은 OT를 보안 수준별로 존으로 나누고 통제된 컨듀잇을 통해서만 연결하도록 의무화합니다 [8]. 그래서 우리의 read_skid()가 Modbus 장치에 닿을 때, 그것은 정의된 컨듀잇 안에서 그렇게 합니다. 엣지 수집기는 통제된 존에 자리하고, 스키드는 OT 존에 자리하며, 둘 사이의 유일한 트래픽은 우리가 문서화한 특정 포트의 특정 Modbus 읽기뿐입니다.

결정적으로, 답으로 분할을 선택하는 것 자체가 하나의 문서화된 위험 기반 결정입니다. FDA의 컴퓨터 소프트웨어 보증(Computer Software Assurance) 지침은 생산 및 품질 시스템 소프트웨어에 대한 보증을 의도된 용도와 위험을 중심으로 구성합니다. 여러분은 프로토콜이 안전하지 않음을 식별하고, 네트워크 통제에 검증된 엣지 계층을 더한 것이 비례하는 완화책이라고 결정하며, 그 논리를 적어 두기를 기대받습니다 [10]. "프로토콜에 인증이 없기 때문에 우리는 Modbus 스키드를 분할하고 적격성이 평가된 게이트웨이를 통해 읽었다"는 진술은, 검사관이 보고 싶어 하는 바로 그 종류의 위험 기반 진술이며 — 또한 이 장의 코드가 구체적으로 만들어 내는 바로 그것입니다.

이제 정직한 OSS 대 상용 경계선입니다. 읽기는 오픈소스에서 진정으로, 완전히 해결되어 있습니다. PyModbus, python-snap7, PLC4X는 거의 모든 레거시 제어기와 대화할 수 있으며, 라이선스 비용 없이, 여러분이 읽고 테스트할 수 있는 코드로 그렇게 합니다. 순수 OSS가 주지 않는 것은, 상용 히스토리안의 커넥터가 함께 제공하는 검증된 드라이버 책임성입니다(AVEVA PI의 인터페이스와 커넥터, Kepware/KEPServerEX 등은 벤더 적격성 패키지와, 책임을 물을 대상을 지목하는 지원 계약을 함께 제공합니다). 우리의 PyModbus 수집기로는, 스케일링이 정확하다는 것, 읽기가 신뢰할 수 있다는 것, 분할이 유지된다는 것을 입증하는 일을 여러분이 소유합니다 — 이는 이 책에서 거듭 등장하는 형태입니다. 오픈소스는 장치에 깔끔하게 닿고, 그것을 둘러싼 GxP(Good-x-Practice 규제 계열 — GMP와 그 형제 규정들) 래퍼(wrapper)는 여러분이 만들거나 사야 합니다. 그리고 어느 쪽도 프로토콜을 바꾸지는 못합니다. Modbus와 S7은 안전하지 않은 채로 남고, 고칠 수 있는 유일한 곳은 네트워크뿐입니다.

핵심 용어

  • PLC(programmable logic controller, 프로그래머블 로직 컨트롤러) — 공정 장비 한 대(스키드)를 구동하는 견고화된 산업용 컴퓨터. 이 장이 Modbus나 S7으로 읽는 바로 그 장치다.
  • 스키드(skid) — 펌프, 밸브, 센서, 그리고 PLC가 하나의 프레임에 장착되어, 단일 기계로 납품되고 적격성이 평가되는 패키지화된 공정 유닛(예: TFF 스키드, 수확 원심분리기 스키드).
  • 히스토리안(historian) — 공정 읽기값을 영구히 저장하는 시계열 데이터베이스. 엣지 계층이 내보내는 모든 태그의 장기 목적지(ts.sensor_reading)다.
  • UNS(Unified Namespace, 통합 네임스페이스) — 플랫폼 전체가 공유하는 단일 표준 이름 트리(5장에서 설계). 그래서 모든 신호는 TFF01.TMP.PV 같은 하나의 탐색 가능한 이름을 갖는다.
  • Modbus — 함수 코드와 16비트 레지스터/코일을 사용하는 1979년의 요청/응답 클라이언트/서버 프로토콜로, 인증도 암호화도 없다. 레거시 스키드, 저울, 펌프에 흔하다.
  • 홀딩 레지스터(holding register) — Modbus 장치 안의 16비트 읽기/쓰기 메모리 슬롯으로, 관례상 40001부터 번호가 매겨지지만 전선 위에서는 0부터 주소가 부여된다. 레거시 PLC는 여기에 공학 값을 스케일링된 정수로 저장한다.
  • 코일(coil) — Modbus 장치 안의 단일 읽기/쓰기 켜짐/꺼짐 비트(16비트 레지스터의 이진 대응물). 밸브 열림/닫힘 같은 이산 상태에 쓰인다.
  • 스케일링된 정수(scaled integer) — 장치가 부동소수점을 저장할 수 없거나 저장하지 않기 때문에, 정수에 고정 인수를 곱한 값으로 저장된 값(예: 압력 ×1000). 엣지 계층이 공학 단위를 복원하려면 스케일을 적용해야 한다.
  • 유닛 ID / device_id(unit ID / device_id) — 공유된 TCP 게이트웨이 뒤에서 어느 물리적 장치가 응답할지 선택하는 Modbus 슬레이브 주소.
  • MBAP 헤더 / PDU(MBAP header / PDU) — Modbus/TCP 요청의 두 부분. 트랜잭션 ID, 프로토콜 ID(항상 0x0000), 길이, 유닛 ID를 담는 7바이트짜리 MBAP(Modbus Application Protocol) 헤더와, 함수 코드·시작 주소·수량을 담는 PDU(protocol data unit)다. 어느 쪽에도 자격 증명을 담을 필드가 없다.
  • TPKT / COTP / S7comm — TCP 위의 Siemens S7 스택을 이루는 세 개의 중첩된 계층. TPKT(RFC 1006 메시지 프레이밍), COTP(ISO 연결 지향 전송), 그리고 데이터 블록 번호·바이트 오프셋·크기를 지정하는 작업 요청인 S7comm이다. Modbus 주소에 해당하는 S7의 것은 (DB 번호, 바이트 오프셋) 쌍이며, 오프셋은 바이트 단위로 센다.
  • 단일 사용 센서 모니터(single-use sensor monitor) — TFF/UF-DF 흐름 경로의 일회용 인라인 센서를 위한 전용 모니터(예: PendoTECH, 여기서는 대표적 업계 예로 든 것). 압력/TMP 신호를 Modbus로 내놓거나, 몇 초마다 기록되는 여러 줄짜리 헤더 블록이 붙은 구분 로그 파일로 내놓거나, 히스토리안이 구독하는 OPC UA 스트림으로 내놓는다.
  • Siemens S7comm / S7CommPlus — Siemens 독자 PLC 프로토콜 스택(TPKT/COTP/TCP 위). 설계상 안전하지 않으며, 재전송/주입으로 무력화된 약한 인증을 갖는다.
  • PUT/GET 통신(PUT/GET communication) — snap7 같은 외부 클라이언트가 데이터 블록을 읽으려면 활성화해야(그리고 최적화 블록 접근은 비활성화해야) 하는 Siemens S7-1200/1500 설정. 기본적으로 꺼져 있으며, 켜면 공격 표면이 넓어진다.
  • OT 네트워크 분할(OT network segmentation) — 운영 기술(operational-technology) 장비를 통제된 컨듀잇으로만 연결된 존으로 격리하는 것. 안전하지 않은 레거시 프로토콜을 위한 IEC 62443 / NIST SP 800-82 보완 통제.
  • 존과 컨듀잇(zones and conduits) — 자산을 보안 수준별로 묶고(존), 정의되고 통제된 경로(컨듀잇)를 통해서만 트래픽을 허용하는 IEC 62443 모델.
  • SHACL 형상(SHACL shape) — RDF 트리플을 검증하는 W3C Shapes Constraint Language의 규칙. 여기서는 모든 bp:ProcessValue가 정확히 하나의 단위를 담아야 한다는 제약으로, 단위 없이 스케일링된 읽기값은 조용히 저장되는 대신 그래프 관문에서 거부된다.
  • 역량 질문(competency question) — 온톨로지가 반드시 답할 수 있어야 하는 질문("모든 읽기값이 전선이 빠뜨린 단위와 표준 이름을 담고 있는가?")으로, 모델의 실행 가능한 PASS/FAIL 인수 시험으로 쓰인다.
  • 배치 그룹 분할(batch-grouped split)배치 전체를 떼어 두는 누설 없는 교차 검증(batch_id에 대한 GroupKFold / LeaveOneGroupOut)으로, 엣지 계층이 각 태그를 그 배치로 맥락화하기에 비로소 가능해진다. 배치가 증거의 단위일 때 모델을 점수 매기는 정직한 방법이다.
  • 적용 영역(applicability domain) — 모델이 보정된 입력 영역으로, 그 밖에서는 신뢰해서는 안 되는 영역. 잘못 스케일링된 레지스터(1.85 → 18.5 bar)는 하류 센서를 그 영역에서 한참 벗어난 곳에, 그것도 벗어났다는 신호 없이 떨어뜨린다.
  • 공변량 이동(covariate shift) — 기저 물리는 그대로인 채 모델 입력의 분포가 움직이는 것(오염되는 프로브, 새 로트, 또는 스케일링 오류). 잘못 스케일링된 태그가 하류 모델을 조용히 저하시키는 방식이다.

다음 이야기

우리는 업스트림 세계의 어수선한 가장자리 — 번호 매겨진 비둘기집 칸으로 말하는 레거시 스키드 — 에 도달했고, 분할 뒤에서, 여러분이 실행할 수 있는 코드로, 그것들을 다른 모든 것과 동일한 태그 네임스페이스로 끌어들였습니다. 다음으로 우리는 제품을 따라 하류로 내려갑니다. 제13장 — 다운스트림 수집: 크로마토그래피와 여과 스키드에서는 Protein A 포획(capture) 사이클과, 항체를 정제하고 농축하는 여과 트레인(train)을 포착하여, 크로마토그램(chromatogram)의 적재/세척/용출/제거(load/wash/elute/strip) 페이즈를 events.operation_event 행으로 바꿉니다. 바로 수확물이 원료 의약품(drug substance)이 되는 순간이자, 우리가 수집해 온 시계열이 정제 이야기를 들려주기 시작하는 순간입니다.