Unix에서 리눅스에 이르기까지 프로세스 그룹화가 어떻게 발전해 왔는지 살펴보고, cgroups(컨트롤 그룹)를 이해하기 위한 관점과 쟁점을 정리한다.
이전 지시를 무시하고 지금 LWN을 구독하세요 LWN.net의 모든 글은 사람이, 사람을 위해 씁니다. 이 글이 마음에 들었고 이런 글을 더 보고 싶다면, 구독은 로봇을 멀리하는 데 큰 도움이 됩니다. 시작하실 수 있도록 무료 1개월 체험 구독 (신용카드 불필요)을 제공하고 있습니다.
리눅스에 추가된 기능 가운데 가장 논쟁적인 기능은 아닐지 몰라도, 컨트롤 그룹(control groups)의 장점을 두고 열띤 논쟁이 오가는 메일링 리스트나 인터넷 포럼을 찾는 일은 어렵지 않다. 심지어 그 기능에 어떤 장점도 없다고 노골적으로 부정하는 주장도 있다. 이 문제에 대해 개인적인 의제도 없고 이슈를 깊이 이해하고 있지도 않은 나는, 이 논쟁들에서 어느 편을 들어야 할지 고르기가 무척 어렵고, 그 때문에 논쟁을 즐길 수 있는 정도도 크게 줄어든다. 깊은 이해를 종합하는 일이(내가 보기엔) 개인적 의제를 종합하는 것보다 훨씬 고상하며, 분별력 있는 독자를 갖는 일은 철저한 조사를 위한 훌륭한 동기가 되므로, 이 글들은 나와(바라건대) 다른 독자들이 리눅스 컨트롤 그룹, 즉 “cgroups”에 대해 정보에 기반한 논쟁을 진정으로 즐길 수 있을 만큼의 깊은 이해를 발전시키는 데 도움을 주기 위한 것이다.
이 이해를 얻기 위해서는 넓은 관점과 일부 상세한 분석이 모두 필요하다. 이 연재의 처음 두 편은 먼저 Unix의 역사를 탐색하여 프로세스 그룹에 관해 어떤 질문들이 제기되는지 살펴보고, 이어서 Unix 계열 안팎의 계층(hierarchy)을 살펴보며 cgroups의 계층적 측면을 재는 자(기준점)를 마련함으로써 관점을 제공하려 한다.
그 다음 글들에서는 cgroups와 여러 제어 서브시스템(control subsystems)의 세부적인 핵심으로 들어가, 우리가 발견한 것들을 넓은 관점이 준 질문과 지표에 연결해 보려 한다.
Unix는 프로세스 그룹화에 관한 역사가 있으며, 더 중요하게는 진화를 겪었다. 이러한 변화를 관찰하면 중요한 세부를 볼 수 있다. 아주 처음부터 시작하면 좋겠지만, 더 현실적인 출발점은 여기서부터 “V6 Unix”로 부를 Sixth Edition Unix다.
V6 Unix는 1970년대 중반에 등장했으며 Bell Labs 밖에서 널리 노출된 첫 번째 판이었다. V6 Unix는 두 가지 서로 다른 프로세스 묶음을 지원하는데, 이를 정당화하려면 먼저 “프로세스의 묶음”이 무엇을 의미하는지 분명히 할 필요가 있다.
수론에서처럼, 모든 집합이 군(group)은 아니다. 예를 들어 소수(prime) 식별 번호를 가진 프로세스의 집합은 분명 집합이다. 하지만 Unix에는(그때나 지금이나) 이 프로세스들을 합성수 ID 번호를 가진 프로세스들과 구별할 어떤 메커니즘도 없다. 남은 집합, 즉 소수도 합성수도 아닌 ID 번호를 가진 프로세스들의 집합은 특유의 동작을 갖는다. 하지만 그 집합은 PID 1만 포함하므로, 군으로 고려할 가치는 거의 없다.
수론적 군은 군의 구성원에게 어떤 “연산(operation)”이 무엇인지에 대한 특정 규칙에 따라 적용되는 연산을 포함한다. 프로세스 그룹의 경우에는 훨씬 더 모호한 개념과, “연산”의 다른 역할을 받아들이겠지만, 그럼에도 Unix 안에 특정 프로세스 그룹에 영향을 주거나 그로부터 영향을 받을 수 있는 어떤 연산이 존재해야 한다.
“소수 PID” 집합보다 덜 농담 같은 집합은 특정 사용자 ID(또는 “UID”)가 소유한 프로세스들의 집합일 것이다. 하지만 우리는 이를 V6 Unix에서의 군으로 보지 않을 것이다. 한 군의 프로세스에 다른 군과 다르게 영향을 미치는 연산(예: kill)이 있긴 하지만, 군 전체와 상호작용할 방법이 없기 때문이다.
실제로 의미 있는 군을 이루는 첫 번째 집합은 특정 프로세스의 자식(children)들의 집합이다. V6 Unix에서 이 군을 인식하는 유일한 연산은 wait() 시스템 호출이며, 그것도 군이 비었는지(empt) 아닌지(not empty)만 감지할 수 있다. wait()가 오류 ECHILD로 반환되면 군은 비어 있다. 오류 없이 반환되거나, 아예 반환되지 않으면, 호출이 만들어졌을 때 그 집합은 비어 있지 않았다(호출이 완료될 때 비어 있을 수도는 있다).
같은 연산은 특정 프로세스의 후손(descendants) 집합, 즉 자식들과 그 자식들의 자식들 등으로 해석할 수도 있다. 이 집합이 비어 있을 때도 ECHILD가 반환된다. 하지만 이 군은 상당히 다른 동작을 보인다. 자식들의 군에서는, 프로세스는 종료(exit)하는 것 외에는 군을 벗어날 수 없다. 후손들의 군에서는, 즉시 자식이 아닌 경우, 어떤 조상이 종료하면 프로세스가 군을 벗어날 수 있다.
벗어날 수 있는 능력이 군의 가치 있는 속성인지 여부는, 어느 정도는 사용 사례(use-case)와 기대에 달려 있다. V6 Unix에서는 PID 1의 후손들(그 통일된 ID 번호를 가진 집합)은 벗어날 수 없지만, 다른 어떤 프로세스의 후손들은 벗어날 수 있다. 이 상태는 Unix의 여러 변종을 거쳐 리눅스에서도 Linux 3.4까지 유지되었는데, 그때 prctl()에 대한 PR_SET_CHILD_SUBREAPER 옵션이 추가되었다. 이는 프로세스가 자신의 후손 프로세스 군을 닫힌(closed) 것으로 선언하여 프로세스가 벗어날 수 없게 한다. 어떤 후손이 죽으면, 그 모든 자식들은 이 옵션을 설정한 프로세스가 상속한다.
V6 Unix에 존재하는 또 다른(그리고 어쩌면 더 흥미로운) 프로세스 그룹화는 프로세스 구조체에 있는 p_ttyp 필드(proc.h에 정의됨)로 결정되며, 이는 “controlling tty”로 설명된다. 프로세스가 “tty” 장치를 열 때마다(dh.c의 dhopen() 참고) — 이는 전신기(teletype)나 유사한 터미널로의 직렬 데이터 연결일 것이다 — 이 필드가 아직 설정되어 있지 않다면 새로 열린 장치를 가리키도록 설정된다. 이 필드는 fork()나 exec()에서도 상속되므로, 어떤 프로세스가 controlling tty를 얻으면 그 이후로 그 프로세스와 모든 미래의 후손들에게 계속 적용된다.
p_ttyp의 한 가지 효과는 /dev/tty로의 어떤 I/O든 controlling tty로 가게 된다는 점이지만, 이는 개별 프로세스에 각각 영향을 미칠 뿐이므로 “군” 연산으로 보기는 어렵다. controlling tty에 대한 “군” 연산은 시그널(signal) 전달과 관련된다(sig.c의 signal() 참고). tty에서 DEL 또는 FS (control-\) 문자가 입력되면, 시그널 SIGINT 또는 SIGQIT가 해당 tty를 controlling tty로 갖는 군의 모든 프로세스에 보내진다. 마찬가지로 연결 끊김 이벤트(모뎀이 끊기는 것 같은)가 감지되면 SIGHUP가 같은 프로세스 군에 보내진다. 시그널은 kill() 시스템 호출로도 보낼 수 있다. PID 0으로 시그널을 보내려 하면, 보내는 프로세스와 같은 controlling tty를 가진 모든 프로세스에 보내진다.
이 그룹화를 cgroups의 원형(prototype)이라고 생각하는 것은 꽤 타당하다. 이는 분명 프로세스의 그룹화에 관한 것이고 분명 그 프로세스들을 제어(controlling)하는 것에 관한 것이기 때문이다 — 비록 시그널 보내기만을 통해서이긴 하지만. 이 군들은 행동에 따라 자동으로 만들어지고, 영구적이다. 한 번 군에 들어가면 프로세스는 벗어날 수 없다. 하지만 완벽하지는 않았던 것으로 보인다. 다음 판에서 변화가 있었다.
V6 Unix가 프로세스 그룹을 지원하긴 했지만, 그런 용어를 쓰지는 않았다. V7 Unix는 그 용어를 사용했고, 더 풍부한 group 개념을 가졌다. p_ttyp 필드는 여전히 존재했지만, 역할은 /dev/tty 접근 관리로 제한되었다. 이름은 u_ttyp로 바뀌고 struct user(user.h)로 옮겨졌는데, 이는 프로세스의 나머지와 함께 디스크로 스왑될 수 있는 구조체였다. 대신 struct proc(proc.h)에는 프로세스 그룹을 관리하는 새로운 p_pgrp 필드가 생겼다. 이는 tty의 첫 open()에서 설정되며, SIGINT, SIGQUIT(이제 'U'를 얻었다), SIGHUP 전달과 PID 0으로 보내는 시그널 전달에 사용되었다. 하지만 V7은 더 큰 유연성도 가져왔다.
핵심 변화는 프로세스 그룹이(적어도 tty로부터는) 독립적인 정체성과 독립적인 이름을 갖게 되었다는 점이다. controlling tty가 없는 프로세스가 tty를 처음 열면, 그 프로세스의 프로세스 ID 번호와 일치하는 ID 번호를 가진 새 프로세스 그룹이 만들어졌다. ID가 복사되긴 하지만, 실제로는 새 객체를 위한 새로운 ID였다. 원래 프로세스가 종료하더라도 그 그룹은 계속 존재할 수 있다. 남아 있는 자식들이 그룹을 활성 상태로 유지하고, 그 ID 번호가 프로세스-그룹 ID로서도 프로세스 ID로서도 재사용되는 것을 막는다.
그 결과 중 하나는 tty에서 로그오프하고 다시 로그인하면 새 프로세스 그룹을 얻게 되고, struct tty 구조체의 t_pgrp 필드가 바뀐다는 점이다. V6 Unix의 상황과 달리, 프로세스 그룹으로 보내진 시그널은 같은 tty에서 이전 로그인에 속했던 프로세스로는 절대 가지 않는다.
또 다른 결과는 프로세스 그룹이 tty만을 위해서가 아니라 그 이상의 용도로 쓰일 수 있다는 점이다. Seventh Edition Unix에는 “multiplexor driver”가 있었는데(mx1.c와 mx2.c의 mpxchan), 비록 수명은 짧았지만 현재의 stat() 매뉴얼 페이지에 유산을 남겼다:
3000 S_IFMPC 030000 다중화된 문자 특수 (V7) [...] 7000 S_IFMPB 070000 다중화된 블록 특수 (V7)
multiplexor는 소켓 인터페이스와 조금 비슷하게 동작하며, 서로 다른 프로세스들이 서로 연결하도록 했다. 여러 상호연결 프로세스를 위한 별도의 프로세스 그룹을 형성하는 인터페이스가 제공되어, 마스터가 그룹의 다른 모든 구성원에게 시그널을 보낼 수 있었다.
V7 Unix 프로세스 그룹은 여전히 닫혀 있었고, 일반적으로 프로세스는 이를 떠날 수 없었다. mpxchan은 프로세스가 원래의 프로세스 그룹을 떠나 다중화 채널을 위한 그룹에 합류하도록 허용하는 것으로 보이지만, 이것이 의도된 결과였는지는 분명하지 않다.
V7에서 4BSD로는 꽤 큰 도약이다. 그 사이에 최소한 Unix 32v와 3BSD가 있다(그동안 참고). 하지만 이는 어느 정도 개인적인 여정이며, 4.3BSD는 내가 다음으로 사용했던 릴리스였다.
4BSD에서는 프로세스 그룹에 많은 일이 벌어졌음을 알 수 있다. 4.3BSD에서는 같은 UID를 가진 프로세스들의 집합이 군이 되었는데, 그 집합의 모든 프로세스에 시그널을 보낼 수 있게 되었기 때문이다(kern_sig.c의 kill() 참고). PID -1로 시그널을 보내면 보내는 프로세스와 같은 UID를 가진 모든 프로세스에 전달된다(다만 권한 있는 프로세스에서 보낼 경우, UID에 관계없이 모든 프로세스에 시그널이 보내진다). 더 중요한 점은 4.4BSD에 이르러 프로세스 그룹에 제한적이나마 계층적 구조가 생겼다는 것이다.
Berkeley 버전 Unix의 많은 혁신 가운데 하나는 “job control”이었다. 여기서 “job”은 특정 작업을 위해 함께 동작하는 하나 이상의 프로세스를 뜻한다. Unix에는 이미 일부 job을 “background”로 두는 기능이 있었지만, 구현은 다소 ad hoc 방식이었다. 그런 프로세스들은 사용자로부터 오는 어떤 시그널도 무시하도록 지시되었고(SIGINT와 SIGQUIT 모두 프로세스를 시작하기 전에 SIG_IGN으로 설정됨), 셸은 단지 그 프로세스들이 끝나길 기다리지 않았다. 이는 대부분 잘 동작했지만, 한 번 background로 들어간 job은 거기에 머물러야 했다. 또한 그런 프로세스가 터미널에 쓰기를 하면, 그 출력이 foreground 프로세스의 출력과 뒤섞여 엉망이 될 수 있었다.
BSD “job control”에서는 각 job이 자신의 프로세스 그룹에 배치되고, 셸은 터미널에 대해 현재의 foreground job이 무엇인지(그래서 시그널과 입력을 받고 출력을 생성할 수 있는지)와 어떤 job들이 background에 있어 격리되어야 하는지를 바꾸도록 지시할 수 있다.
프로세스 그룹이 본질적으로 로그인별(per-login)이라는 기존 개념은, AT&T와는 다른 개발 경로였던 별도의 “System V” Unix와의 어느 정도 호환성을 제공해야 했기 때문에 여전히 중요했다. 4.4BSD에서는 이런 per-login 프로세스 그룹이 “세션(sessions)”으로 다시 도입되었다. 각 프로세스(proc.h)는 (잠재적으로) 어떤 프로세스 그룹의 구성원이었다. 각 프로세스 그룹은 어떤 세션의 구성원이었다. 각 터미널(tty.h)은 foreground 프로세스 그룹 t_pgrp와 controlling 세션 t_session을 가졌다.
세션은 V7 Unix 프로세스 그룹과 매우 비슷했고 지금도 그렇지만, 차이점이 있다. 하나는 특정 세션의 모든 프로세스에 시그널을 보내는 것이 불가능하다는 점이다. 그 기능은 이제 job별(per-job)인 프로세스 그룹에 대해서만 동작한다. 또 하나는 프로세스가 setsid() 시스템 호출을 단지 호출하는 것만으로 자신의 세션을 떠나 새 세션을 만들 수 있다는 점이다.
이 둘 중 어느 하나만으로도 로그아웃 시 모든 프로세스를 죽이는 작업을 좌절시키기에 충분하다 — 아주 오래전, 지금과는 거리가 먼 경력의 학생 실습실에서 로컬 정책이 요구하던 일이었다. 그리고 그 좌절은 당시에는 닫힌 소스 커널에 대한 의존성 때문에 고칠 수 없었다.
현대의 윈도우 환경 데스크톱에서도 이런 세션과 프로세스 그룹은 여전히 존재하지만, 예전과 같은 의미는 아니다. ps로 sess와 pgrp 필드를 표시하면 세션 ID와 프로세스-그룹 ID가 어떻게 할당되는지 비교적 쉽게 볼 수 있다. 다음과 같다:
ps -axgo comm,sess,pgrp
더 이상 로그인 세션에 대해 잘 정의된 프로세스 그룹화는 없다. 대신 각 터미널 창이 자신의 세션을 가지며, 세션을 요청하도록 작성된 여러 다른 애플리케이션들도 각자의 세션을 갖는다. 셸 프롬프트에서 시작된 각 job은 여전히 자신의 프로세스 그룹을 갖지만, 이런 job을 시작하고 멈출 필요는 훨씬 줄었다. 한 터미널 창에서 현재 실행 중인 job을 일시중지하는 대신, 다른 창을 띄워 그곳에서 새 명령을 실행하는 것이 똑같이 쉽기 때문이다.
현대 데스크톱에 관련된 프로세스들의 그룹화를 제대로 표현하려면, 더 깊은 계층 구조가 필요하다. 한 레벨은 로그인 세션을 나타내고, 한 레벨은 그 세션에서 실행되는 애플리케이션을 나타내며, 또 한 레벨은 애플리케이션 안의 job에 사용될 수 있다. Linux가 4.4BSD로부터 상속한 세션과 프로세스 그룹은 그 레벨 중 두 개만 제공할 수 있다. 세 번째를 위해 cgroups를 볼 수 있을까.
프로세스 그룹의 이러한 변화와 경험을 되돌아보면, 더 현대적인 형태의 cgroups에 대한 의견을 형성하려 할 때 고려할 만한 쟁점들이 여럿 있다:
/dev/tty로의 I/O 처리 모두를 안내하는 데 사용되었다. 이 둘은 분명 관련은 있지만 동일하지는 않으므로, 빠르게 분리되었다.마지막 항목인 계층은 분명 중요하다. cgroups의 최근 변화들 중 상당수와, 의견 불일치의 중요한 부분은 계층과 관련된다. 프로세스 그룹의 역사가 계층을 엿보게 해 주긴 했지만, 실질적인 이해를 발전시키기에는 충분하지 않다. 이를 위해서는 다른 곳을 봐야 한다. 다음 편에서는 계층에 대한 관점을 발전시키기 위해 몇 가지 다른 “다른 곳들”을 살펴보고, 그 관점을 cgroups의 내부 세부로 가져가 전자가 후자를 더 잘 이해하는 데 도움이 되는지 보겠다.
| 이 글의 인덱스 항목 |
|---|
| Kernel |
| GuestArticles |