네트워크 스위치를 죽인 범인은? '허브리스 버그' 이야기
(cliffle.com)- Oxide의 네트워크 스위치 펌웨어가 전원 시퀀싱 변경 테스트 뒤 켜지지 않았고, 원인은 Hubris 커널의 IPC 메모리 대여 검사가 새 메모리 배치 방식과 충돌한 버그였음
- Hubris는 MPU로 태스크를 격리하는 임베디드 운영체제이며, IPC에서 다른 태스크에 메모리를 빌려줄 때 해당 메모리가 실제 접근 가능한 영역인지 커널이 확인함
- 최근 도입된 태스크 패킹은 일부 펌웨어 이미지에서 RAM을 30% 회수했지만, 기존 검사는 대여 메모리가 단일 MPU 영역 안에 있다고 가정해 실패함
sequencer태스크는 I2C 드라이버에0x801bffd주소가 포함된 메모리를 빌려주려다 synthetic memory fault로 죽었고,humility tasks에는 115회 재시작과mem fault... in syscall상태가 남음- 수정은 인접한 여러 MPU 영역을 가로지르는 대여를 허용하도록 검사 알고리듬을 바꾸는 방식이었고, 장애 발견부터 커널 버그 수정까지 약 3시간이 걸림
켜지지 않는 네트워크 스위치
- Oxide의 Arjen Roodselaar는 네트워크 스위치 펌웨어에서 전원 시퀀싱과 클록 설정 변경을 테스트하다가, 사소해 보이는 변경 뒤 스위치가 켜지지 않는 문제를 만남
- 펌웨어의 일부는 질의에 응답했지만, 전원 공급 시퀀서를 담당하는 중요한 부분은 멈춘 것처럼 보였음
- 전원 시퀀싱 오류는 하드웨어를 실제로 손상시킬 수 있어, 스위치가 죽었는지 단순히 응답하지 않는지부터 확인해야 했음
Hubris와 제한된 메모리
- Hubris는 키보드 내부 컨트롤러 같은 깊은 임베디드 시스템을 위한 운영체제이며, Oxide Rack의 큰 프로세서를 시작하는 데 필요한 작업을 처리하기 위해 만들어짐
- Hubris 기반 펌웨어는 별도로 컴파일되는 여러 프로그램인 태스크(task) 로 구성됨
- 각 태스크는 필요한 표준 라이브러리 코드 등을 자체적으로 가짐
- 태스크들은 하드웨어 MPU로 격리되어 서로를 크래시시키거나 메모리를 손상시키지 않도록 설계됨
- 주로 사용하는 ARM Cortex-M 계열 ARMv7-M에서는 보호 메모리 영역이 2의 거듭제곱 크기여야 하고, 그 크기에 맞춰 정렬되어야 함
- 예를 들어 1024바이트 영역에 1바이트가 더 필요하면 1025바이트가 아니라 2048바이트 영역이 됨
태스크 패킹이 만든 새 경계
- 초기 Hubris는 태스크 RAM에 한 영역, flash에 한 영역을 쓰는 단순한 방식을 사용했지만, 태스크 사이에 쓰지 못하는 빈 공간이 생겨 메모리가 낭비됐음
- Matt Keeter는 빌드 시스템이 가능한 경우 여러 power-of-two 영역을 조합해 태스크를 배치하도록 개선함
- 하드웨어는 태스크당 최대 8개 영역만 허용함
- 일부 펌웨어 이미지에서는 RAM을 30% 회수함
- 가장 작은 장치들은 매번 최적화가 필요할 만큼 빡빡한 상태에서 여유 공간을 갖게 됨
- 이 변경으로 태스크의 flash와 RAM 중간에 예측하기 어려운 MPU 영역 경계가 생길 수 있게 됨
humility tasks가 남긴 단서
- Arjen은 Hubris 디버거인 Humility로 실패한 스위치를 조사했고, 전원 시퀀싱을 맡은 서비스 프로세서는 살아서 실행 중이어서 하드웨어 문제 가능성은 낮아 보였음
humility tasks출력에서sequencer태스크는 다음 상태를 보였음
mem fault (precise: 0x801bffd) in syscall (was: wait: reply from i2c_driver/gen0)
- 같은 태스크는 115회 재시작된 상태였고, Hubris에서는 거의 항상 크래시에 대한 반응으로 태스크를 재시작함
- 상태 문자열은 다음을 뜻함
mem fault: 메모리 처리 규칙 위반precise: 0x801bffd: 문제가 된 구체적 주소를 알 수 있음in syscall: 태스크가 실행 중이 아니라 시스템 콜 중이었음was: wait: reply from i2c_driver/gen0: I2C 드라이버에 보낸 메시지의 응답을 기다리던 중이었음
gen0는i2c_driver가 아직 크래시한 적이 없다는 뜻이고,sequencer는 generation 115였음
Hubris IPC와 메모리 대여
- Hubris 태스크는 IPC 메시지로 통신하며, 메시지는 함수 호출처럼 동작함
- 메시지를 보내는 태스크는 멈춤
- 받는 태스크가 CPU 제어권을 가짐
- 결과가 돌아오면 보낸 태스크가 다시 깨어남
- IPC는 Rust의 소유권 모델과 잘 맞도록 설계되어, 태스크가 자신의 메모리 일부를 IPC 메시지와 함께 다른 태스크에 빌려줄 수 있음
- I2C 장치와 상호작용하는 태스크는 자신의 메모리 구간을 I2C 버스 드라이버에 빌려주고, 드라이버는 그 구간을 제자리에서 읽거나 씀
- 버스 드라이버가 별도 버퍼 풀을 가질 필요가 줄어듦
- 데이터 복사 횟수가 줄어듦
- 잘못 구현하면 보안 구멍이 될 수 있어, Hubris 커널은 태스크가 실제로 소유하거나 접근할 수 없는 메모리를 빌려주는 것을 금지함
- 서버는 오류 코드를 받음
- 클라이언트는 fault를 받으며 항상 종료됨
- 이는 버그, 손상, 익스플로잇 가능성을 나타내는 접근 위반으로 취급됨
synthetic fault와 실제 원인
- Hubris는 fault를 real fault와 synthetic fault로 구분함
- real fault는 null pointer 역참조나 코드 영역 쓰기 같은 하드웨어 규칙 위반임
- synthetic fault는 IPC나 메모리 대여처럼 Hubris가 추가한 소프트웨어 규칙 위반임
sequencer의 fault는 IPC를 통해 I2C 드라이버에 메모리를 빌려주는 과정에서 발생한 synthetic fault였음- 문제가 된 주소
0x801bffd는 유효한 flash 주소였지만, 2의 거듭제곱 경계보다 3바이트 아래라 이상한 패턴을 보였음 humility mem출력은sequencer태스크에 속한 두 flash 영역이0x801c000에서 맞닿아 있음을 보여줌
LOW HIGH SIZE ATTR ID TASK
0x08018000 - 0x0801bfff 16kiB r-x--- 17 sequencer
0x0801c000 - 0x0801dfff 8kiB r-x--- 17 sequencer
- 두 영역은 같은 태스크에 속하므로 정상적인 프로그램 실행에서는 하드웨어 MPU가 문제없이 접근을 허용할 수 있었지만, 커널의 IPC 메모리 대여 검사는 다른 가정을 갖고 있었음
오래된 단순화가 버그가 된 지점
- 기존 커널 검사는 대여하려는 메모리 slice 전체가 태스크의 단일 영역 안에 완전히 들어가는지만 확인했음
self.region_table().iter().any(|region| {
region.covers(slice)
&& region.attributes.contains(desired)
&& !region.attributes.intersects(forbidden)
})
- 이 코드는 작성 당시의 설계, 즉 태스크당 RAM 영역 하나와 flash 영역 하나라는 전제와 맞았음
- 태스크 패킹이 도입되면서 같은 태스크의 메모리가 인접한 여러 MPU 영역으로 나뉠 수 있게 되었고, 기존 가정은 더 이상 성립하지 않음
- 일반 메모리 접근은 하드웨어 MPU가 직접 검사하므로 영향을 받지 않았고, 해당 메모리를 IPC로 빌려주려 할 때만 문제가 드러남
두 기능이 함께 만든 장애
- 태스크 패킹은 기회주의적으로 동작함
- 태스크당 최대 8개 영역 제한이 있음
- 하드웨어 드라이버 태스크는 메모리 매핑 레지스터 때문에 일부 영역을 이미 사용함
- 남는 영역 슬롯이 있을 때만 더 영리한 배치를 시도함
- 그 결과 영역 경계는 태스크 작성자가 예측하기 어려운 위치에 생김
- 태스크 A의 작은 크기 변화가 무관한 태스크 B의 MPU 영역 경계 위치를 바꿀 수 있음
- 디버깅 코드를 추가하는 것만으로도 배치 결정과 영역 경계가 바뀌어 크래시가 사라질 수 있는 상황이 됨
- Matt는 즉시 빌드 시스템에서 태스크 패킹을 꺼서 Arjen이 동작하는 펌웨어 이미지를 만들 수 있게 했고, 동시에 커널 버그 분석과 수정이 진행됨
커널 수정 방식
- 수정의 핵심은 대여하려는 메모리가 정확히 인접한 여러 MPU 영역을 가로질러도 허용하도록 메모리 접근 검사 알고리듬을 바꾸는 것이었음
- 새 알고리듬은 영역 테이블을 한 번만 훑도록 설계됨
- Hubris는 태스크가 제어할 수 있는 시간 복잡도를 가진 연산 노출을 피하려고 함
- 성능은 빌려주는 메모리 크기가 아니라 고정 크기인 영역 테이블에만 의존해야 했음
- 영역 테이블 크기는 8개로 고정됨
- 이를 위해 빌드 시스템은 태스크 영역을 주소 오름차순으로 정렬하도록 바뀜
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);
- 수정 커밋은 커널이 이 정렬 속성을 이용해 더 저렴한 접근 검사를 하도록 만듦
- 복잡해진 코드는 Hubris 커널 핵심부에서 분리되어 더 이식성 있는 crate로 옮겨졌고, 중요한 corner case에 대한 단위 테스트가 추가됨
- 새 코드로 태스크 패킹을 다시 켤 수 있게 되었고, 태스크 개발자에게 예측하기 어려운 크래시를 남기지 않게 됨
장애가 크게 번지지 않은 이유
- 전체 흐름은 네트워크 스위치가 켜지지 않는 상태에서 시작해, 약 3시간 만에 커널 버그 수정까지 이어짐
- fault isolation 덕분에 23개 격리 태스크로 구성된 스위치 펌웨어 중
sequencer만 반복적으로 죽었고, 다른 구성요소 상당수는 계속 동작함- 펌웨어 업데이트 시스템
- 관리·제어 인터페이스용 IP 네트워크 스택
echo프로토콜 구현부터 랙 제어 평면 인터페이스까지의 여러 네트워크 서비스- 센서, 팬, 기타 시스템 상태 모니터링용 I2C, SMBus, PMBus
- 전면부 32개 QSFP 100G 트랜시버 드라이버
- Hubris IPC는 다른 태스크가 실패할 수 있다는 가정으로 설계되어, 멱등으로 표시된 작업은 투명하게 재시도될 수 있음
- 기존 메모리 접근 검사 버그는 올바른 프로그램의 접근을 막는 형태였고, 잘못되거나 악의적인 접근을 허용하는 형태가 아니어서 보안 영향은 없었음
sequencer와 I2C 드라이버가 사실상 메모리를 공유하던 순간sequencer가 죽었지만, I2C 드라이버는 손상 위험 없이 계속 동작했음
디버깅 인프라와 팀 운영
- Humility는 Hubris 커널과 함께 발전한 디버거이며, Arjen은 몇 분 안에 크래시한 코드 위치를 라인 번호 수준까지 식별하고 서비스 프로세서의 독립적인 스냅샷을 공유할 수 있었음
- Hubris는 크래시한 태스크의 압축 코어덤프를 RAM에 기록하고, 네트워크를 통해 회수할 수 있음
- 쓰기 가능한 영구 저장소가 없어도 크래시 덤프를 얻을 수 있음
- 크래시덤프 기능은 커널이 아니라 별도 태스크에 있음
- 해당 프로세서들은 고객 워크로드 데이터를 다루지 않고 시스템 관리 트래픽만 처리하며, 크래시 보고서는 자동 업로드되지 않음
- Hubris 커널의 아키텍처 독립 부분은 코드 1,789줄과 주석 1,192줄 규모이며, ARMv6-M·ARMv7-M·ARMv8-M 지원은 코드 1,075줄과 주석 534줄을 더함
- 커널 개념과 IPC가 단순해, fault가 IPC를 가리킬 때 확인해야 할 위치가 많지 않았음
댓글과 토론
Hacker News 의견들
-
Hubris가 정말 좋음. 커널 코드를 30분쯤 읽어봤는데, 예전에 봤던
ifdef매크로 범벅, 두 글자 변수명 선호, 주석 부족한 C 코드와는 거리가 멀고 아주 명확하게 잘 쓰여 있음
자기 전 읽을거리로도 좋고, 한번 훑어보길 권함: https://github.com/oxidecomputer/hubris/blob/b44e677fb39cde8...- C 문화의 상당 부분이 “적당한 속도로 타이핑하는 법을 배우기 귀찮다”로 요약되는 것 같아 꽤 거슬림
소스 코드의 디스크 공간은 40년 전부터 큰 문제가 아니었는데, 아직도 변수명에는 인색함 - AI가 이 관습을 없앨 수도 있음. 낡고 거친 C 코드를 AI에 넣으면, 갑자기 모든 변수가 깔끔해지고 사용자가 좋아하는 방식으로 이름 붙을 수 있음
AI가 특정 코더의 취향과 버릇을 정확히 학습했기 때문임
- C 문화의 상당 부분이 “적당한 속도로 타이핑하는 법을 배우기 귀찮다”로 요약되는 것 같아 꽤 거슬림
-
좋은 글이지만, 아래 주석 위치는 아쉬움
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);위의 “주소 오름차순으로 정렬해야 하며 커널이 이 속성을 이용해 접근 검사를 싸게 한다”는 주석은 이 함수의 세부사항이라기보다 모든 작성자가 지켜야 하고 모든 읽는 쪽이 활용할 수 있는 필드 불변조건임
그래서TaskDesc::regions문서 문자열에 넣는 편이 맞아 보임: https://github.com/oxidecomputer/hubris/commit/b44e677fb39cd...- 그래도 정렬 코드 옆에 주석이 있는 건 좋음. 아니면 그 정렬 자체가 꽤 의외로 보일 수 있음
아마 가장 좋은 방식은TaskDesc에 생성자 메서드를 만들어 영역을 정렬하게 해서 불변조건을 강제하는 것임. 코드가 시간이 갈수록 복잡해지는 게 보이니, 이제는 복잡성을 메서드 안으로 감싸는 데 시간을 조금 쓰는 게 가치 있어 보임
- 그래도 정렬 코드 옆에 주석이 있는 건 좋음. 아니면 그 정렬 자체가 꽤 의외로 보일 수 있음
-
지금까지 본 것 중 손꼽히게 좋은 채용 공고임. 자연스럽게 문화 이야기로 넘어가고 끝에 “참고로 채용 중”이 붙는 흐름이 좋음
정말 훌륭한 사후 분석 글이고, 애플리케이션 계층 개발자인 나도 따라갈 수 있었음. 마침 지금 Rust in Action을 읽는 중이라 이런 내용에 더 준비돼 있기도 했음
코드에 주석을 많이 다는 사람을 보는 것도 늘 즐거움. 문학적 프로그래밍은 통함- 아쉽게도 미국에서만 해당되는 얘기임
-
전편은 여기서 확인할 수 있는 듯함
-
“팀의 촘촘한 비계층적 통합”이라는 부분이 눈에 띔. Hubris 기능 자체는 아니지만, Hubris와 이를 만든 팀을 분리하기 어렵고, Oxide 엔지니어링 팀에는 사실상 내부 사일로가 없다는 설명이 인상적임
개방성, 호기심, 소통을 장려하고 방어성, 제국 만들기, 게이트키핑을 억제하는 문화를 왜 만들었는지와 구체적으로 어떻게 구현했는지 더 듣고 싶음. 이런 문화를 조직 안에서 키울 때 단점은 없는지도 궁금함
더 엄격한 계층형 시스템을 택하는 곳도 있고, 조직도는 전략적으로 정해야 할 수 있으니 트레이드오프가 잘 감이 안 옴- 명시된 가치 자체는 평가하기 어렵지만, 일반적으로 강하게 정의된 구조가 없는 조직의 단점은 그래도 어떤 형태의 권력 구조가 생긴다는 것임
그 구조가 명시되지 않으면 덜 공개적이고, 의도적으로 선택된 것도 아니며, 특히 사회적 상호작용에 능숙하지 않은 사람에게는 이해하기 더 어려움. 그래서 그림자 같은 성격 때문에 더 병적인 행동을 허용할 수 있고, 아주 나빠지지 않더라도 조율을 훨씬 어렵게 만들 수 있음
이런 일을 여러 회사에서 겪어봤음. 한 대형 컨설팅 회사는 공식 권력 구조가 있긴 했지만 실제로는 잘 지켜지지 않았고, 프로젝트에 들어가는 방식도 공식 채널보다는 영업·관리하는 사람들과 친해지는 쪽에 가까웠음. 필요한 사회적 네트워크를 잘 만들 수 있으면 좋았지만, 그렇지 않으면 잘 작동하지 않았음
비슷한 예로 “The Tyranny of Structurelessness”가 있음. 계층을 가부장적인 것으로 보고 거부하던 조직 안에서 같은 일이 벌어지는 걸 본 페미니스트의 강연이고, 내부 구조가 명확하지 않은 Valve에서도 비슷한 논의가 있음. 오픈소스 프로젝트도 같은 문제를 겪을 수 있고, Rust 쪽 일부 갈등도 비슷한 문제에서 비롯됐다고 봄
그렇다고 명시적 권력 구조가 꼭 계층적일 필요는 없음. 전통적인 비즈니스 조직은 계층형이지만, Oxide의 구조는 명시적이면서도 비계층적일 수 있음. 이런 방식은 보통 규모가 작을수록 더 잘 작동하고, 앞서 말한 컨설팅 회사는 자유형에 가깝게 운영되는 회사 중 내가 아는 가장 큰 사례였지만 그래도 어느 정도 받쳐주는 발판이 있었음
이것은 이분법이 아니라 스펙트럼임. 종이 위로는 가장 경직된 권력 구조라도 그 아래에는 더 복잡한 암묵 구조가 있고, 그게 사람 집단의 본성임
명시적 구조가 언제나 암묵적 구조보다 낫다고 믿는 건 아님. 덜 명시적인 조직에서 관찰되는 단점을 말한 것뿐이고, 더 명시적인 권력 구조도 자기 문제를 가짐. 관련해서 “seeing like a state”나 가독성 문제도 있음
- 명시된 가치 자체는 평가하기 어렵지만, 일반적으로 강하게 정의된 구조가 없는 조직의 단점은 그래도 어떤 형태의 권력 구조가 생긴다는 것임
-
복잡한 문제를 디버깅하는 과정을 깊이 있게 보여주는 훌륭한 글임. 나머지 시스템이 안정적으로 유지됐다는 사실은 Oxide 팀의 엔지니어링 품질을 잘 보여줌
개인적으로도 꽤 영감을 받았고, 비슷한 기법을 일상 업무에 적용해볼 생각임 -
그 하드웨어를 소프트웨어 채움 TLB처럼 다루면 8개보다 많은 영역도 지원할 수 있음
- 아마 그들은 (a) 소프트 실시간 성능을 원하고, (b) 디버깅 가능성이나 신뢰성을 방해할 수 있는 핵심 요소를 넣고 싶지 않았을 것 같음
마지막 선택지가 아니면 절대 하지 않을 것임. 가상 페이징은 지저분하고, 의심을 남기고 싶지 않음 - TLB가 translation lookaside buffer라는 건 알겠는데, 여기서 “soft fill”은 무슨 뜻인지 궁금함
- 아마 그들은 (a) 소프트 실시간 성능을 원하고, (b) 디버깅 가능성이나 신뢰성을 방해할 수 있는 핵심 요소를 넣고 싶지 않았을 것 같음
-
Oxide가 하는 일은 정말 놀라움
- Tailscale에 이어 이제 Oxide가, 99%의 사람에게는 필요 없을 프로젝트들의 사랑받는 존재가 됨
-
Oxide 사람들이 하는 건 뭐든 마음에 들고, 이것도 그중 하나임
-
운영체제 이름을 Hubris라고 지었다고? 아, 그건… 차마 말도 못 하겠음
- 디버거 이름은 “humility”라는 걸 알면 기쁠 것임: https://github.com/oxidecomputer/humility
- 정확히는 Brian Cantrill이 운영체제 이름을 hubris로 지은 것임
제정신인 누가 요즘 새 운영체제를 쓰겠나? 답은 모든 운영체제가 외면하는 문제, 즉 운영체제가 제어하지도 못하고 제어할 수도 없는 메인보드와 확장 카드의 컨트롤러 문제를 풀려는 사람임 - 꽤 브랜드에 잘 맞아 보임