8년 동안 400명 이상의 기여자와 6,000개 이상의 커밋이 진행되었으며 지속적으로 개발 중인 osquery는 복잡한 프로젝트로, 수백만 대의 호스트에서 다양한 주요 기업들에 의해 배포되어 안정적인 성능과 신뢰성을 제공합니다. 이 글은 osquery 시스템 아키텍처에 대한 이해를 높이고자 하는 사용자, osquery에 기여하고자 하는 개발자, 그리고 성공적인 오픈 소스 프로젝트의 아키텍처에서 배울 것이 있는 모든 분들을 위한 것입니다.
osquery에 대한 처음 접하는 분들을 위해, 실제로 프로젝트가 어떻게 사용되는지를 소개하는 "Monitoring macOS hosts with osquery"로 시작하는 것이 유용할 수 있습니다.
쿼리 엔진
osquery의 약속은 익숙한 SQL 방언을 사용하여 계측 데이터를 일관된 방식으로 제공하여 일반 사용자들이 고급 분석을 수행할 수 있도록 하는 것입니다. osquery는 단순히 SQLite 구문을 사용하는 것이 아니라, 쿼리 엔진 자체가 SQLite입니다. osquery는 쿼리 구문 분석, 최적화 및 실행 기능을 모두 SQLite에서 가져오므로 프로젝트가 계측 데이터의 가장 관련성 있는 소스를 찾는 데 집중할 수 있습니다.
중요한 점은 osquery가 SQLite 쿼리 엔진을 사용하지만 실제 데이터 저장소로는 SQLite를 사용하지 않는다는 것입니다. 대부분의 데이터는 "가상 테이블"이라는 개념을 통해 쿼리 실행 시 동적으로 생성됩니다. 그러나 osquery는 호스트에 일부 데이터를 저장해야 하며, 이를 위해 내장형 RocksDB 데이터베이스를 사용합니다 (나중에 설명됨).
가상 테이블
가상 테이블은 osquery의 핵심입니다. 이들은 분석을 위해 제공되는 모든 데이터를 수집합니다. 대부분의 가상 테이블은 쿼리 실행 시 데이터를 생성하며 파일을 구문 분석하거나 시스템 API를 호출하여 데이터를 생성합니다.
테이블은 Python에서 구현된 DSL을 통해 정의됩니다. osquery 빌드 시스템은 테이블 정의 파일을 읽어들이고 어떤 플랫폼을 지원하는지를 결정하기 위해 디렉토리 구조를 활용하고, 그런 다음 SQLite이 테이블에서 데이터를 동적으로 검색할 수 있도록 모든 구성 요소를 연결합니다.
쿼리 실행 시 SQLite 쿼리 엔진은 가상 테이블에 데이터 생성을 요청합니다. osquery 코드는 SQLite 테이블 제약 조건을 가상 테이블 구현이 사용하여 데이터를 생성하는 데 최적화하거나 완전히 결정하도록 번역합니다.
예를 들어 etc_hosts와 같이 간단한 가상 테이블을 살펴보겠습니다. 이 테이블은 단순히 /etc/hosts 파일을 구문 분석하고 각 항목을 별도의 행으로 출력합니다. 이 경우 가상 테이블 구현에게 쿼리 매개 변수를 받을 필요가 거의 없습니다. 어차피 파일 전체를 읽기 때문입니다. 가상 테이블이 데이터를 생성한 후에는 SQLite 엔진이 WHERE 절에서 제공된 필터링을 수행합니다.
반면에 users와 같은 테이블은 쿼리 컨텍스트를 활용할 수 있습니다. 사용자 테이블은 uid나 username이 제약 조건으로 지정되었는지 확인하고 관련 사용자의 메타데이터만 로드하도록합니다. 사용자를 완전히 열거하지 않고 SQLite이 필터링을 수행하는 것도 괜찮지만 요청된 데이터만 생성함으로써 약간의 성능 이점을 얻을 수 있습니다. 다른 경우에는 이러한 성능 차이가 훨씬 극적일 수 있습니다.
마지막으로, 쿼리 제약 조건을 보려면 어떤 일도 수행하지 못하도록 테이블을 만들 수 있습니다. 파일의 해시를 계산하는 해시 테이블을 예로 들어보겠습니다. 이 테이블은 제약 조건이 없으면 어떤 파일에 대해 작업을 수행할 파일을 알지 못합니다 (시스템의 모든 파일을 해시하려고 시도하는 것은 재앙적일 것이므로). 그래서 결과를 반환하지 않을 것입니다.
osquery 개발자들은 커뮤니티 기여자들에게 가상 테이블 생성을 쉽게 만들기 위해 많은 노력을 기울였습니다. 간단한 스펙 파일을 만들고 (Python에서 구현된 사용자 정의 DSL을 사용) C++ (또는 필요한 경우 C/Objective-C)로 구현하면 됩니다.
빌드 시스템은 모든 기존 테이블과 완전히 호환되도록 새로운 테이블을 자동으로 연결합니다.
테이블 예시:
table_name("etc_hosts", aliases=["hosts"])
description("Line-parsed /etc/hosts.")
schema([
Column("address", TEXT, "IP address mapping"),
Column("hostnames", TEXT, "Raw hosts mapping"),
])
attributes(cacheable=True)
implementation("etc_hosts@genEtcHosts")
이벤트 시스템
osquery에 의해 노출되는 모든 데이터가 테이블 쿼리 시 동적으로 생성되는 모델에 적합하지 않는 것은 아닙니다. 파일 무결성 모니터링 (FIM)의 일반적인 문제를 예로 들어보겠습니다. 시스템에서 중요한 파일의 해시를 캡처하는 쿼리를 매 5분마다 실행하도록 예약하면 공격자가 해당 파일을 변경한 다음 다음 스캔 전에 변경 사항을 되돌릴 수 있는 간격을 놓칠 수 있습니다. 지속적인 가시성이 필요합니다.
이러한 문제를 해결하기 위해 osquery에는 이벤트 발행자/구독자 시스템이 있으며 이 시스템은 적절한 가상 테이블이 쿼리될 때 노출될 데이터를 생성, 필터링 및 저장할 수 있습니다. 이벤트 발행자는 자체 스레드에서 실행되며 발행할 이벤트 스트림을 생성하기 위해 필요한 API를 사용할 수 있습니다. Linux에서 FIM의 경우 발행자는 inotify를 통해 이벤트를 생성합니다. 그런 다음 이벤트를 하나 이상의 구독자에게 게시하고 필터링 및 저장을 원하는 대로 수행할 수 있습니다. 마지막으로 사용자가 이벤트 기반 테이블을 쿼리하면 관련 데이터가 저장소에서 검색되고 다른 테이블 결과와 마찬가지로 SQLite 필터링 시스템을 통해 실행됩니다.
스케줄러
osquery 스케줄러에는 매우 신중한 설계 고려 사항이 포함되어 있습니다. Facebook의 100만 대 이상의 프로덕션 호스트에서와 같이 대규모로 osquery를 배포하는 경우, 모든 호스트가 정확히 동일한 시간에 동일한 쿼리를 실행하고 리소스 사용량이 동시에 급증하는 것은 큰 문제가 될 수 있습니다. 그래서 스케줄러는 정확한 간격이 아닌 대략적인 간격으로 쿼리를 실행할 수 있도록 무작위 "분산"을 제공합니다. 이 간단한 설계는 전체 호스트 집합에서 리소스 급증을 방지합니다.
또한 스케줄러가 시계 시간이 아니라 실행 중인 osquery 프로세스의 틱에서 작동한다는 것이 중요합니다. 서버에서 (수면 모드가 없는 경우) 이것은 실제로 시계 시간이 될 것입니다. 그러나 노트북에서 (사용자가 덮개를 닫으면 종종 수면 모드) osquery는 컴퓨터가 활성 상태인 동안에만 틱할 것이므로 스케줄러 시간은 시계 시간과 잘 대응하지 않을 것입니다.
차이 엔진
대규모 최적화 및 가장 관련성 있는 데이터를 확인하기 위해 osquery는 차이 쿼리 결과를 출력하기 위한 시설을 제공합니다. 각 쿼리 실행 시 해당 쿼리의 결과는 내부 RocksDB 저장소에 저장됩니다. 로그를 출력할 때 현재 쿼리의 결과와 기존 쿼리의 결과를 비교하고 추가/제거된 행의 로그를 제공할 수 있습니다.
이것은 선택 사항이며 "스냅샷" 모드에서 쿼리를 실행할 수 있으며 결과가 저장되지 않고 쿼리 결과 집합 전체가 각 예약된 쿼리 실행 시 출력됩니다.
RocksDB
osquery에서 제공하는 데이터 중 많은 부분은 쿼리 실행 시 시스템 상태에서 동적으로 생성되지만, 에이전트가 데이터를 저장해야 하는 많은 상황이 있습니다. 예를 들어 이벤트 시스템은 쿼리 실행 간에 이벤트를 버퍼링하기 위한 백업 저장소가 필요합니다.
이를 달성하기 위해 osquery는 다른 Facebook 오픈 소스 프로젝트인 RocksDB를 사용합니다. 이것은 높은 쓰기 최적화가 되어있는 내장형 키-값 데이터베이스로 osquery 바이너리에 컴파일됩니다.
RocksDB는 이벤트, 차이 로깅을 위한 예약된 쿼리 결과, 구성 상태, 버퍼링된 로그 등을 저장하기 위해 사용됩니다.
구성 플러그인
osquery가 실행되는 다양한 환경에서 설정을 가져와야 할 수 있으므로 데몬에 필요한 구성을 제공하는 "구성 플러그인"이라는 개념이 있습니다. 구성의 일반적인 소스는 파일 시스템 (Chef, Puppet 등에 의해 생성된 구성 파일) 또는 TLS 서버를 통해 가져올 수 있습니다.
구성 플러그인은 다음과 같은 API를 준수합니다 (Go 구문):
func GenerateConfigs(ctx context.Context) (map[string]string, error)
구성 플러그인은 확장 관리자에 의해 호출될 때 osquery 구성 (JSON 형식)을 반환하고 이 구성은 osquery 데몬에 전달됩니다.
로거 플러그인
구성과 마찬가지로 로깅도 다양한 아키텍처와 호환되어야 합니다. 일반적으로 사용되는 로깅 플러그인은 파일 시스템 (때로는 splunkd 또는 logstash와 같은 에이전트로 전달), TLS 또는 AWS Kinesis/Firehose입니다.
로거 플러그인은 다음과 같은 API를 준수합니다 (Go 구문):
func LogString(ctx context.Context, typ logger.LogType, logText string) error
osquery에서 로그 (상태 또는 결과 로그)를 생성할 때 이 로그는 로거 플러그인에 기록되며 로그를 필요한 방식으로 처리할 수 있습니다.
분산 플러그인
분산 플러그인은 osquery에서 데이터를 원격으로 실시간으로 쿼리하는 기능을 제공합니다. 현재 존재하는 분산 플러그인의 구현은 TLS입니다. 이는 osquery 시스템의 필수 부분은 아니지만 Kolide Fleet과 같은 라이브 쿼리의 편리한 기능을 활성화합니다.
분산 플러그인은 다음과 같은 API를 준수합니다 (Go 구문):
func getQueries(ctx context.Context) (*distributed.GetQueriesResult, error)
func writeResults(ctx context.Context, results []distributed.Result) error
이 API를 사용하면 osquery 프로세스가 프로세스 외부 컨트롤러에서 실행할 쿼리를 검색하고 해당 컨트롤러로 결과를 작성할 수 있습니다.
정적 컴파일
다양한 엔드포인트에 대한 배포 부담을 줄이기 위해 osquery는 대부분의 (많은) 종속성을 정적으로 연결된 단일 실행 파일로 빌드합니다. 이 실행 파일에는 osqueryi 및 osqueryd가 포함되며 전체 osquery 배포를 활성화하는 데 필요한 모든 내장 플러그인이 포함됩니다.
osquery는 C++ 프로젝트이며 다수의 종속성이 있는데 이를 정적 바이너리로 빌드하는 것은 어려운 문제입니다. 그러나 osquery 개발자들은 이러한 종속성을 모두 가져오고 정적 실행 파일을 빌드하기 위한 빌드 시스템을 만들었습니다. 그 결과 바이너리는 거의 모든 시스템에서 사용 가능한 기본 공유 라이브러리만 필요로하며 더 특이한 종속성은 포함됩니다.
와치독
실행 중인 쿼리가 시스템 성능에 지나치게 많은 영향을 미치지 않도록하기 위해 osqueryd에는 "와치독"이 있으며 "워커" osquery 프로세스를 생성합니다. 이와치독 프로세스는 실행 중인 쿼리를 모니터링하고 미리 정의된 성능 임계값을 초과하는 경우 워커 프로세스를 종료합니다. 또한 성능 문제를 일관적으로 유발하는 쿼리를 블랙리스트에 등록하여 사용자가 이러한 쿼리를 나중에 디버깅하면서 즉시 문제를 해결할 수 있도록 합니다.
이로써 osquery의 주요 아키텍처 요소와 내부 동작 원리에 대한 요약을 제공했습니다. osquery는 강력한 오픈 소스 프로젝트로, 시스템 모니터링과 보안 분석에 유용한 다양한 기능을 제공합니다.
원문 : https://www.kolide.com/blog/osquery-under-the-hood
댓글