서버의 폭력성 선택
(cliffle.com)- Hubris는 격리된 태스크들이 IPC로 통신하는 OS이며, 13번째 시스템 호출인
REPLY_FAULT로 서버가 잘못된 클라이언트 요청을 오류값 대신 fault로 종료할 수 있게 함 - 클라이언트 입장에서는 IPC가 함수 호출처럼 보이지만, 태스크가 별도 컴파일되기 때문에 잘못된 작업 코드, 해석 불가능한 바이트, 부적절한 loaned memory를 컴파일러가 모두 막지는 못함
- 정상적인 Hubris 프로그램은 빌드 설정과 생성된 Rust 코드 덕분에 이런 오류를 거의 만나지 않으므로, 모든 호출에
Result<T, IpcError>와unwrap()을 강제하면 코드 크기와 런타임 비용이 늘어남 - 커널은 시스템 호출 전제조건을 어긴 태스크를 오류 코드 없이 즉시 죽이며,
REPLY_FAULT는 같은 fail-fast 정책을 서버 응답까지 확장함 - 이 설계는 잘못된 API 사용을 빠르게 드러내지만, 무작위 IPC와 시스템 호출을 보내는 fuzz test나 chaos 태스크는 거의 즉시 재시작되어 테스트가 어려워짐
Hubris IPC와 REPLY_FAULT의 위치
- Hubris는 작은 애플리케이션 독립 커널을 두고, 드라이버·애플리케이션 로직·네트워크 스택 같은 대부분의 코드를 분리 컴파일된 격리 태스크에 둠
- 태스크 간 통신은 커널이 구현한 IPC 시스템 호출로 이뤄짐
RECV: 가장 높은 우선순위의 수신 메시지를 가져오거나 메시지가 올 때까지 블록됨SEND: 호출자를 멈추고 메시지와 제어권을 수신 태스크에 넘긴 뒤, 응답을 받을 때까지 대기함REPLY: 이전에SEND한 태스크에 응답을 전달해 다시 실행할 수 있게 함
- Hubris의 클라이언트와 서버는 고정된 신분이 아니라 태스크가 수행하는 역할임
SEND를 쓰는 태스크는 클라이언트 역할RECV와REPLY를 쓰는 태스크는 서버 역할- 한 태스크가 어떤 태스크에는 서버이면서 다른 태스크에는 클라이언트일 수 있음
태스크 경계에서 컴파일러가 놓치는 오류
- 일반적인 함수 호출에서는 컴파일러와 링커가 타입과 호출 대상을 상당 부분 보장함
- Rust 함수가
String인자를 받으면 호출자가bool을 넘기는 일은 컴파일러가 막음 pet_cat을 호출하려다fire_missiles를 호출하는 식의 대상 혼동도 보통 일어나지 않음
- Rust 함수가
- Hubris IPC는 태스크 경계를 넘고 각 태스크가 별도 프로그램으로 컴파일되므로, 컴파일러가 전체 IPC 관계를 직접 검증하지 못함
- IPC 서버가 마주칠 수 있는 오류는 크게 세 가지임
- 인터페이스에 맞지 않는 작업 코드, 예를 들어 두 작업만 있는 인터페이스에 “operation number 48”이 들어오는 경우
- 기대한 메시지 타입이 아니라 해석 불가능한 바이트 묶음이 오거나, 메시지가 너무 짧거나 긴 경우
- 필요한 loaned memory가 없거나, 쓰기 가능 메모리가 필요한데 읽기 전용으로 오는 경우
정상 프로그램에 오류 처리를 강제하지 않는 이유
- 정상적인 Hubris 프로그램에서는 이런 IPC 오류가 발생하지 않도록 구성됨
- 태스크 연결은 빌드 시스템 설정으로 구성되어 서로를 혼동하기 어려움
- 클라이언트는 생성된 Rust 코드로 IPC를 구성하고 전송함
- 서버도 별도의 생성 Rust 코드로 결과를 처리함
- 모든 IPC 작업이
Result<T, IpcError>를 반환하게 만들면, 정상 프로그램은 실제로 만날 수 없는 오류에 대해unwrap()을 넣어야 함unwrap()은 코드 크기 측면에서 부담이 큼- 런타임에도 일어나지 않을 오류를 검사하는 비용이 생김
- 생성 코드 안에
unwrap()이나panic!을 넣으면 panic 위치를 중앙화해 코드 크기 영향은 줄일 수 있지만, 런타임 비용은 그대로 남음 - 보편 오류 코드를 지원하려면 모든 작업이 같은 오류 인코딩 규칙을 따라야 함
- 모든 작업은 오류를 반환할 수 있어야 함
- 모든 작업은 해당 오류를 같은 방식으로 인코딩해야 함
- 실패할 수 없는 작업도 실패 가능한 형태로 표현해야 함
- Hubris 기반 펌웨어에서는 실제로 실패할 수 없는 작업이 계속 발견됐으며, GPIO 핀 설정이 그 예임
Hubris 커널의 공격적인 fault 정책
- 많은 운영체제는 시스템 호출 전제조건을 어겨도 오류 코드를 반환하거나 예외·시그널 처리 기회를 줌
- Unix에서 열지 않은 파일 디스크립터를
close하면 오류 코드가 반환됨 open에 경로명 대신 null pointer를 넘겨도 오류 코드가 반환됨
- Unix에서 열지 않은 파일 디스크립터를
- Hubris는 시스템 호출 전제조건을 깨면 해당 태스크를 즉시 파괴함
- 태스크는 더 이상 명령을 실행할 수 없음
- 태스크 자체에는 복구하거나 재개할 기회가 없음
- 애플리케이션의 supervisor 태스크는 fault를 통지받고, 보통 태스크를 지운 뒤 재시작함
- 커널이 만드는 fault는 synthetic fault임
- null pointer 역참조나 0으로 나누기처럼 CPU가 만드는 하드웨어 fault와 유사함
- 하드웨어 fault는 프로세서 아키텍처 규칙 위반에서 나오고, synthetic fault는 커널 규칙 위반에서 나옴
- 예를 들어
SEND호출에서 수신 태스크 인덱스가 애플리케이션 범위를 벗어나거나, 메시지 포인터가 접근 권한 없는 메모리를 가리키면 synthetic fault가 발생함 - Hubris는 복구 가능하거나 재개 가능한 fault를 허용하지 않음
- 하드웨어 fault든 synthetic fault든 fault를 받은 태스크는 죽은 상태가 됨
- 이 선택은 미묘한 실패 모드를 피하고 시스템 추론을 단순화하기 위한 것임
서버가 클라이언트를 fault로 응답하는 방식
REPLY_FAULT는 서버가 클라이언트에 정상 응답 대신 fault를 전달하는 시스템 호출임- 일반적인
REPLY흐름은 다음과 같음- 클라이언트가
SEND를 쓰면 커널은 클라이언트 태스크를 수신 태스크에 대한 “waiting to send” 상태로 표시함 - 수신 태스크가
RECV를 쓰면 해당 클라이언트는 “waiting for reply” 상태가 됨 - 서버가
REPLY를 호출하면 클라이언트는 runnable 상태로 돌아감
- 클라이언트가
REPLY_FAULT는REPLY와 비슷하지만, 메시지를 전달하고 실행 가능 상태로 만드는 대신 fault를 전달해 태스크를 죽은 상태로 만듦- 서버가 임의의 태스크를 죽일 수는 없음
REPLY_FAULT는 해당 서버가RECV했고 아직REPLY하지 않은 태스크에만 사용할 수 있음- 특정 서버의 응답을 기다리는 클라이언트에 대해서만 동작함
- Hubris는
REPLY_FAULT를 다음 오류 처리에 사용함- 잘못된 작업 코드
- 손상·잘림·무의미한 메시지
- 클라이언트가 올바른 종류의 loaned memory를 보내지 않은 경우
애플리케이션 오류와 fail-fast 경험
REPLY_FAULT는 IPC 형식 오류뿐 아니라 애플리케이션별 오류에도 사용할 수 있음- Hubris IP 스택은 IP 포트를 태스크에 정적으로 할당함
- 어떤 태스크가 다른 태스크의 IP 포트를 건드리려 하면 IP 스택이 해당 태스크에 fault를 줌
- 이 방식은 실제로 발생하지 않아야 하는 “이론적” 오류 처리를 줄이고, 잘못된 사용을 개발 중에 빠르게 드러냄
REPLY_FAULT는 Rust 함수 호출 전제조건 위반 시 일반적으로panic!이 발생하는 모델과 유사하게, 서버가 클라이언트 프로세스에 대해 교차 프로세스panic!을 일으키는 수단이 됨- 클라이언트는 이를 위한 코드를 포함하거나 협조할 필요가 없음
보안 성향과 테스트상의 제약
- Eliza Weissman은 Hubris를 “악성 프로그램에 공격적으로 적대적”이라고 표현함
- 악용 시도는 종종 API 오류나 오용으로 먼저 나타나므로, 잘못 행동한 컴포넌트의 상태를 지우는 시스템은 악용하기 더 어려울 수 있음
- 이 가설은 아직 테스트되지 않았음
- Hubris exploit 시도에 관심이 있으면 연락해 달라는 요청이 포함됨
- 관찰된 단점은 시스템이 fuzz test하기 매우 어렵다는 점임
- 무작위 IPC와 시스템 호출을 생성하는 작은 chaos 태스크가 구현됐지만, 거의 무엇을 하든 즉시 재설정됨
- 유용하게 동작하려면 시작할 때마다 관찰 가능하게 달라지는 시스템 uptime counter에 결정을 의존해야 함
REPLY_FAULT는 서버가 클라이언트를 무작위로 죽여 chaos를 강제하는 방법도 제공하지만, 이 선택지는 아직 완전히 평가되지 않았음- 일반적인 Hubris 태스크는 의도적으로 잘못된 IPC 메시지를 동적으로 생성하지 않으므로, 보통
REPLY_FAULT의 존재를 의식하지 않고 실행될 수 있음
댓글과 토론
Hacker News 의견들
-
REPLY_FAULT는 시스템이 작고 촘촘하며, 애플리케이션도 전체 시스템을 설계한 사람들이 주로 작성할 때는 좋아 보임
하지만 애플리케이션 개발자 입장에서는, 다른 서비스가 언제든 내 프로세스에 즉사 알약을 돌려줄 수 있는 IPC 모델로 서드파티 코드와 연결하는 건 꽤 무서울 듯함
다른 애플리케이션 개발자를 그 정도로 신뢰하지 않음. 세상에는 형편없는 운전자와, 관리자의 압박에 시달리는 개발자가 만든 백그라운드 프로세스가 가득하고, 8시 전에 퇴근할 수만 있다면 부적절할 수도 있는 기본 REPLY_FAULT를 잔뜩 넣을 가능성이 있음- 그건 의도된 설계로 보이고, Hubris가 겨냥하는 환경이 바로 그런 쪽임
- 실제로 Symbian에서 이런 일이 있었음. IPC 서버가 클라이언트를 패닉시킬 수 있었고, OS 소스 코드에 접근할 수 없는 애플리케이션 개발자 입장에서는 꽤 끔찍했음
모든 사전 조건을 쉽게 이해할 수 있는 것도 아니었고, 기기나 OS 버전에 따라 달라질 수도 있었음 - 일탈을 빠르게 죽이는 방식은 시스템을 촘촘하게 유지하는 방법임. 설계된 범위 자체가 어차피 작게 유지해 줄 가능성이 큼
범위는 늘어나기 마련이지만, 호스트에서 처리하는 편이 나은 작업을 굳이 임베디드 컨트롤러 안의 Hubris 태스크로 밀어 넣고 싶어 하진 않을 것 같음 - 임베디드 환경에서는 이런 오해가 누구 책임이든 발생 즉시 해결하는 편이 좋아 보임
서버가 “저 클라이언트가 잘못됐다”고 하면 커널이 그 클라이언트를 죽임. 핵심은 둘이 서로를 이해하지 못했다는 데 있음 - 여기서 서비스는 OS 인터페이스라고 보면 됨. 단일 커널에서 잘못된 커널 호출을 하면 OS가 그 프로세스를 죽이는 것도 합리적임
또 “프로세스”라고 했을 때 생각하는 것과 다를 수 있음. Hubris에서는 스레드들이 모두 같은 주소 공간을 공유함
-
REPLY_FAULT가 연쇄됨? 예를 들어 A가 B에
SEND하고 기다리고, B가 C에SEND하고 기다리는데, C가REPLY_FAULT를 하면 A도 B와 함께 죽는지 궁금함
그렇지 않다면 악의적인 태스크가 실험을 보조 태스크에 위임하면 그만임. 반대로 그렇다면 전체적으로 꽤 취약해 보임, Hubris를 더 잘 알지는 못하지만
게다가SEND가 순환적이거나 상호적일 수 있다면 태스크가 실수로 자기 자신을 죽일 수도 있음. B → A → B 같은 경우에는 REPLY_FAULT를 쓰지 않게 만드는 유인이 될 수도 있음- Hubris는 범용 운영체제로 설계된 것 같지 않음. 프로세스는 빌드 시점에 정의됨
서버가 클라이언트를 되쏠 수 있는 이유는 보안이 아니라 신뢰성임. 오류는 의도적 공격이 아니라 버그에서 나온다고 보는 셈이고, 커널의 극단적인 반응은 개발자가 최대한 빨리 문제를 찾게 해줌
물론 보안과 겹치는 부분은 있고, 프로세스가 해서는 안 되는 일을 하려 할 때 유용한 예비 방어책이 될 수 있음 - B가 fault되면 A는 서버가 죽었다는 오류를 받고, 새로 재시작된 서버에 같은 메시지를 다시 보낼 기회를 얻을 것 같음. 연쇄 충돌은 아닐 듯함
- Hubris는 범용 운영체제로 설계된 것 같지 않음. 프로세스는 빌드 시점에 정의됨
-
Hubris와 디버거인 Humility는 시간이 있거나 해야 할 임무가 있다면 깊이 파고들고 싶은 기술임. 아쉽게도 지금은 불가능함
-
한 팀이 모든 코드를 작성하는 시스템에서는, 클라이언트가 이상하게 쳐다봤다는 이유만으로 궤도에서 날려버리는 방식이 반복 개발 속도를 높일 수 있다는 점이 흥미로움
대수적 효과를 읽다가 잠든 뒤 아침에 이 글을 읽으니 재미있음. 살짝 비틀어 보면, 이건 서버가 클라이언트가 처리할 수 없는 효과를 수행하게 해주는 커널임
코드 재사용과 조합은 훨씬 어려워질 것 같지만, 실행 모델은 훨씬 단순해짐. 정적인 임베디드 시스템에서는 분명 맞는 절충임. 재사용이 필요하면 언제든 태스크를 벤더링해서 수정하면 됨- 예상 가능한 오류, 예를 들어 파일 없음과, 예상 밖 오류인 잘못된 연산 코드 사이를 잘 나누면 일반 프로그램에서도 재사용성이 크게 나빠질 것 같진 않음
오히려 Unix에는 무시 가능한 오류가 너무 많고, 개인적으로는 그중 상당수가 치명적 시그널을 발생시켰어야 한다고 봄. 그러면 전반적인 소프트웨어 품질이 꽤 좋아졌을 것임
예를 들어 잘못된 파일 디스크립터에close()를 호출하는 건 치명적이지 않은 오류라 자주 무시됨. 하지만 실제로는 특히 멀티스레드 앱에서 매우 위험함. 대부분은 잘못된 파일 디스크립터 닫기가 harmless하게 실패하지만, 1%는 로깅 소켓이나 데이터베이스 잠금 파일, 무관한 IPC 연결을 닫아버림. 그렇게 모두가 싫어하는 불안정한 소프트웨어가 만들어짐
- 예상 가능한 오류, 예를 들어 파일 없음과, 예상 밖 오류인 잘못된 연산 코드 사이를 잘 나누면 일반 프로그램에서도 재사용성이 크게 나빠질 것 같진 않음
-
Errand of Mercy의 대사 “여러 규칙과 규정이 있다는 걸 알게 될 것이다. 게시될 것이다. 그중 가장 작은 것을 위반해도 죽음으로 처벌받는다”가 떠오름
-
이걸 HTTP용 만우절 RFC로 만들어야 함
HTTP 499 “Shame on you.”를 제안함. 499를 받은 클라이언트는, 아마Strict: true같은 특정 헤더로 시작한 요청에 한해서, 그 요청을 발행한 태스크를 언어별 방식으로 종료해야 함
이런 맥락에서 보이는 “뭐야 이건… 그런데 사실, 괜찮은데?”라는 균형을 완벽히 맞춤 -
매우 재미있게 읽었고, 이 단일 supervisor 방식은 예전 스타트업에서 모든 것을 unwrap하도록 애플리케이션을 구성했던 방식과 비슷함
좋아하는 글 중 하나인 https://medium.com/@mattklein123/crash-early-and-crash-often...도 떠올랐음 -
이게 정말 너무 공격적인지 궁금함
Linux에서는 소켓만으로 통신 중인 다른 프로그램을 직접 충돌시키는 건 불가능함, 소켓에 잘못된 데이터를 보내는 경우는 제외하고
하지만 죽이는 건 확실히 가능함. root로 실행 중인 것은 무엇이든 다른 것을 죽일 수 있고, 재부팅해서 전체 시스템을 내릴 수도 있음
조금 더 어렵고 흔하지는 않지만, 적어도 컨테이너에서는 root 권한이 흔함. 물론 cgroup이 있어서 더 제한되긴 하지만 요지는 그렇다는 것임
“받는 것은 관대하게, 내보내는 것은 보수적으로”라는 통상적 지혜와도 조금 다름. 다만 그건 네트워크 시스템에 더 묶인 이야기일 수 있음
그래도 시스템이 받아들이는 것에 관대해야 하는 건 어쩔 수 없는지도 모르겠음. 그렇지 않으면 기존 프로그램을 깨지 않고 API를 살짝 바꾸는 방법이 없지 않나?- Hubris는 범용 OS가 아니라 Oxide 서버 랙 안의 저수준 프로세서에서 동작함
런타임에 새로운 종류의 프로세스를 허용하지도 않는 것으로 알고 있음. 가능한 실행 파일은 모두 컴파일 시점에 정해져야 함
- Hubris는 범용 OS가 아니라 Oxide 서버 랙 안의 저수준 프로세서에서 동작함
-
“문제를 고치고 태스크를 재개할 방법은 없다. 이는 미묘한 실패 모드를 피하고 시스템 추론을 단순화하기 위한 의식적 선택이었다”는 부분에 대해, 아인슈타인의 유명한 말 “가능한 한 단순하게, 하지만 더 단순하지는 않게”가 떠오름
이 설계는 뒤쪽 조건을 어기는 것 같음. 현실 세계의 혼란을 전혀 견디지 못하는 운영 환경에는 관심이 없고, 상업적으로 성립하는 영역 중 그런 걸 받아들일 곳도 잘 모르겠음
결국 init 시스템으로 되돌려 계속 재시도하게 하자는 건가? 그런데 어떤 메커니즘으로 발생한 fault를 이해해서 더 나은 방식으로 다시 시도할 수 있나?
어쨌든 신념의 순수함에는 박수를 보냄- Hubris는 학술적 실험이 아님. Oxide 랙의 모든 핵심 요소, 즉 컴퓨트 sled, 스위치, 전원 shelf 컨트롤러 중심에서 동작하고, 그 설계는 무엇보다 실제 제공되는 효용을 바탕으로 함
실제로 Cliff가 블로그에서 자세히 썼듯, REPLY_FAULT는 처음에는 너무 공격적일지도 모른다고 생각했던 기능이었지만, 시스템을 만들고 배포하고, 솔직히 말해 디버깅하면서 얻은 경험이 우리 시스템을 변덕스럽게 망가뜨리는 게 아니라 더 견고하게 만들 것이라는 확신을 줬음
여기서의 사고방식과 실제 모습은 [0]과 [1]에서 더 볼 수 있음
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/ - 워치독 타이머는 주기적으로 찌르지 않는 프로세스를 기꺼이 죽이거나 재시작함
취미 프로젝트에서도 I2C 버스가 프로토콜 비트 하나가 꼬였을 때 자주 멈추고 전체 시스템을 내려버리는 걸 봤기 때문에, 이 설계가 꽤 영감 있다고 생각함
이해한 바로는 이미 알려진 오류 사례, 즉 처리되는 오류가 아니라 프로토콜 불일치와 절대 일어나선 안 되는 것들을 다루는 이야기임
다른 댓글들도 짚었지만 목적 맞춤형 OS임. Erlang으로 UI를 만들지 않을 것처럼, Hubris도 자신이 차지한 공간에 잘 맞아 보임 - 이건 명백히 잘못된 프로그램 상태의 결과인 문제에 적용하려는 발상이라고 봄. 그래서 합리적으로 복구할 수 없음
원인은 버그, 공격, 손상된 하드웨어 중 하나일 수 있고, 어느 경우든 계속 진행하면 안 됨. 호출자에게 심각한 문제가 있으며 계속되면 더 큰 피해만 낼 수 있음
Erlang/OTP의 “let it crash” 철학과 조금 비슷하게 들림. Erlang은 꽤 많은 미션 크리티컬 하드웨어에서 쓰이고 신뢰성으로 유명하니, 실제로는 그렇게 큰 결격 사유가 아닐 수도 있음 - 이건 런타임에 새 태스크 추가를 지원하지 않는 2000줄짜리 Rust 임베디드 시스템 커널임
0xide 서버 랙의 깊숙한 내부에서 돌아가도록 작성된 것임
- Hubris는 학술적 실험이 아님. Oxide 랙의 모든 핵심 요소, 즉 컴퓨트 sled, 스위치, 전원 shelf 컨트롤러 중심에서 동작하고, 그 설계는 무엇보다 실제 제공되는 효용을 바탕으로 함
-
“악용 시도는 흔히 먼저 API 오류나 오용으로 드러나므로, 어떤 오동작에도 잘못 행동한 컴포넌트의 상태를 지우는 시스템은 악용하기 더 어려워야 한다”는 부분에서, 여기서는 애플리케이션이 받아들이는 것을 조금 더 엄격히 검사하는 셈임
그래서 보안상 이점은 있지만, 생각하는 종류와는 다름. 공격자의 진행 상황을 파괴해서 후퇴시키는 게 아니라, 예전에는 더 원하는 잘못된 상태로 이어 붙일 수 있었던 특정 잘못된 상태들이 더 이상 통하지 않게 만든다는 점임
그러면 공격자는 그걸 시도하기보다 다른 곳을 찾게 됨