Linux System에서 UID로 Username 가져오는 방법
옛날 하둡(Hadoop) 에코시스템 전반을 아우르는 빅데이터 성능 관리 솔루션 플라밍고(EXEM Flamingo)를 개발하던 때의 이야기입니다. 당시 저는 웹 브라우저로 수백 대의 서버 상태를 실시간으로 모니터링할 수 있는 대규모 대시보드와 수집 에이전트를 Java로 설계하고 있었습니다.
당시 하둡(Hadoop) 같은 빅데이터 클러스터 환경에서는 누군가 맵리듀스(MapReduce) Job 알고리즘을 잘못 짜서 올리면 그 프로세스가 전체 서버의 자원을 미친 듯이 다 잡아먹거나 영원히 행(Hang)에 걸려 있는 일이 비일비재했습니다. 장애가 났을 때 빅데이터 특성상 수백 대가 넘는 서버에 일당백으로 일일이 SSH로 접속해서 top 명령어를 치고 있을 수는 없었죠.
그래서 전체 수백 대 서버의 자원 현황을 대시보드에서 한눈에 조망하고, 이상이 있는 서버를 클릭하면 그 서버 안에서 돌고 있는 개별 프로세스들의 자원 사용량 추이가 그래프로 쫙 펼쳐지도록 기획했습니다. 특히 원인을 파악하려면 프로세스 아이디(PID), 실행 커맨드, 그리고 결정적으로 “이 프로세스를 실행한 유저명(Username)“이 반드시 필요했습니다. 누군지 알아야 연락해서 Job을 죽일 테니까요.
”대체 htop은 데이터를 어디서 긁어오는 걸까?”
이 정보를 수집하기 위해 각 서버에 뿌려둘 ‘수집 에이전트’를 Java로 개발해야 했습니다. 그런데 수십 개의 프로세스에 대한 CPU, 메모리, 커맨드, 실행 유저 정보를 도대체 1초마다 어디서 그렇게 빨리빨리 가져와야 할지 막막했습니다.
“우리가 매일 쓰는 진짜 htop은 대체 이런 데이터를 어디서, 어떻게 눈 깜짝할 새에 긁어오고 화면에 뿌려주는 거지?”
진짜 궁금해진 저는, 아예 오리지널 htop 소스코드를 통째로 열어 까보고 그 구조를 따라 만들어보기로 결심했습니다.
htop 소스코드에서 발견한 /proc 파싱과 UID 매칭
htop 소스코드를 분석해보니, 모든 프로세스의 기본 데이터(PID, 자원 사용량, 커맨드 등)는 리눅스의 /proc 디렉토리를 무자비하게 파싱해서 가져오고 있었습니다.
그런데 여기서 한 가지 난관이 보였습니다. /proc/[PID]/status 파일을 열어보면 “누가 이 프로세스를 띄웠나” 하는 정보가 친절한 문자열(Username)이 아니라 Uid: 1000 1000 1000 1000처럼 건조한 정수형 UID(User ID)로만 덜렁 적혀 있었던 겁니다.
가장 단순하게는 에이전트에서 Runtime.exec() 같은 걸로 id -nu <UID> 쉘 커맨드를 때리면 되겠지만, 매번 외부 쉘 스크립트를 새로 띄우는 건(fork & exec) 성능 최적화 관점에서도 최악이고, 깐깐한 클러스터 서버들의 권한 장벽을 뚫기도 너무 껄끄럽습니다. htop은 당연히 이런 느린 방법을 쓰지 않았습니다.
아래 다소 뜬금없는 C 코드 메모는, 제가 당시 htop이 UID를 Username으로 변환하는 로직을 어떻게 짰는지 분석하고 감탄하며 제 블로그 메모장에 대충 남겨두었던 바로 그 흔적(UsersTable.c)입니다. (당시엔 저만 알아보면 그만이라 코드 한 덩이만 대충 남겨두었네요.)
/*
htop - UsersTable.c
(C) 2004-2011 Hisham H. Muhammad
Released under the GNU GPL, see the COPYING file
in the source distribution for its full text.
*/
#include "UsersTable.h"
#include <pwd.h> // getpwuid() 함수와 struct passwd 구조체가 선언된 핵심 헤더
#include <sys/types.h>
#include <stdlib.h>
/* ... 중략 (해시테이블 초기화 코드) ... */
// UID를 던져주면 진짜 문자열 Username(char*)을 뱉어내는 핵심 함수
char* UsersTable_getRef(UsersTable* this, unsigned int uid) {
// 1. 역대급 속도: 먼저 해시테이블에 이미 치환해둔 Username이 있는지 찔러본다 (Cache Hit)
char* name = (char*) (Hashtable_get(this->users, uid));
if (name == NULL) {
// 2. 캐시에 없네? 그때서야 <pwd.h>에 있는 getpwuid 시스템 기저 API를 호출한다.
struct passwd* userData = getpwuid(uid);
if (userData != NULL) {
// 3. 찾았다면 매칭된 구조체에서 pw_name(문자열 이름) 필드만 쏙 빼서 복제해두고
name = xStrdup(userData->pw_name);
// 4. 다음번 수집 땐 고생 안 하도록 해시테이블에 기록(Caching)해둔다.
Hashtable_put(this->users, uid, name);
}
}
return name;
}이 코드를 뜯어보고 제 뒤통수를 쳤던 핵심은 두 가지였습니다.
pwd.h의struct passwd활용: 쉘 커맨드를 억지로 실행할 필요 없이, OS 단에서 제공하는getpwuid를 타면 내부적으로/etc/passwd를 훑어서struct passwd구조체로 아주 깔끔하게 떨어뜨려 준다. 즉 매칭 데이터는 이렇게 구하는 거였다.- 해시테이블(Hashtable) 캐싱: 시스템 콜이라도 수백 번 호출하면 당연히 무거운데,
htop은 해시맵을 통째로 띄워두고 한 번 매칭된pw_name을 담아둬서 서버 I/O 자체를 원천 차단해버렸다.
htop을 오마주한 Java 에이전트의 탄생
이 구조를 완벽히 이해한 뒤, 저는 제가 만들던 빅데이터 수집 에이전트의 뼈대를 오리지널 htop의 데이터수집 로직과 동일한 사상으로 전부 뜯어고쳤습니다.
- 프로세스들의 각종 자원 정보와 커맨드 라인은
htop처럼 리눅스/proc내부 시스템을 직접 파싱해 긁어옵니다. - 유저 데이터 매칭은 쉘 커맨드 방식을 완전히 폐기하고, Java단에서 시스템 콜에 준하는 방식으로 OS 계정 정보를 조회해 매칭합니다.
- 한 번 알아낸 UID ↔ Username 매핑 결과는 Java의
ConcurrentHashMap을 이용한 딕셔너리에 싹 밀어 넣고 캐싱합니다.
이렇게 구조를 개편하고 나니, 에이전트가 1초마다 수십~수백 개의 맵리듀스 PID를 파싱해서 중앙 서버로 쏘더라도 실제 부하는 거의 없었습니다. 서버 디스크나 시스템 콜을 건드리는 건 처음 띄워질 때 유니크한 유저 수효(수 개 남짓)만큼만 발생하고 끝났기 때문입니다. 나머지 수만 번의 반복 수집 주기에는 무조건 메모리 맵에서 O(1) 수준으로 히트하며 광속으로 매칭이 끝나는 가볍고 강력한 에이전트 환경이 안착했죠.
훗날 장애가 났을 때 일일이 터미널 창 수백 개를 띄우는 대신, 여유롭게 웹 대시보드에 접속해 자원을 좀먹는 범인 프로세스와 그 유저명을 한눈에 찾아내던 순간, 그때 구루들의 htop 소스를 까보고 하나하나 따라 만들어 보길 참 잘했다는 생각이 들었던 뿌듯한 기억입니다.
요약
- 빅데이터 모니터링의 딜레마: 수백 대 서버에서 문제가 되는 맵리듀스 범인을 찾아야 하는데, 프로세스 정보는 대체 어디서 어떻게 가져와야 하나 막막했다.
- htop의 소스코드에서 길을 찾다: 진짜
htop은/proc을 파싱해 자원과 커맨드를 가져오고, 유저명은<pwd.h>의 구조체로 시스템 매칭을 한 뒤 곧바로 해시테이블에 통째로 캐싱하여 극한의 최적화를 이루고 있었다. - Java 에이전트 이식: 이 아이디어를 토대로 모니터링 에이전트의 전체 수집(파싱) 로직과 매칭(캐싱) 로직을
htop스럽게 싹 갈아엎었고, 덕분에 강력한 빅데이터 모니터링 솔루션을 성공적으로 운영할 수 있었다.