cgroup v1 경로 하드코딩 — v2에서 조용히 죽은 격리 로직

기초·개념4분조회 –

사용자 환경을 만드는 요청이 몰릴 때 간헐적으로 실패하기 시작했습니다. 뜸할 땐 멀쩡하다가 동시에 여러 개가 들어오면 랜덤하게 깨집니다.

보안 인증 요건 때문에 쓰던 곳(AWS)이 아닌 다른 클라우드에 환경을 새로 구축하면서 OS 를 Ubuntu 22.04 로, Docker 도 최신으로 올린 직후였고, 코드는 하나도 안 바꿨습니다.

사용자가 환경을 하나 만들면 컨테이너가 하나 뜨고, 만들고 지우는 요청이 하루 종일 도는 서버라 컨테이너를 띄우는 쪽 코드는 몇 년째 손을 안 댈 만큼 안정돼 있었습니다. 그 서버 자체는 systemd 서비스로 떠서 Docker 데몬에 붙어 컨테이너를 만들고, 서버를 배포하면 systemd 가 그 서비스를 내렸다 다시 올립니다.

왜 갑자기 — cgroup v1에서 v2로

간헐 실패 자체는 동시성 레이스였습니다. 그런데 코드는 그대로인데 왜 예전엔 없던 문제가 지금 와서 생겼는지가 안 풀렸습니다. 파고들다 보니 오래전부터 있던 격리 로직 하나가 새 환경에서 아무 일도 안 하고 있는 걸 발견했습니다.

이 서비스는 컨테이너를 만들 때마다 보조 프로세스를 하나씩 띄우고 그 보조 프로세스를 자기(서비스) cgroup 밖으로 빼내는 코드가 따로 있었습니다. systemd 서비스는 기본이 KillMode=control-group 이라 서비스가 재시작되면 그 cgroup 안의 자식 프로세스가 전부 같이 죽습니다. 보조 프로세스가 거기 휩쓸리지 않게 미리 빼두려던 겁니다.

cgroup 은 프로세스를 묶어서 CPU·메모리 같은 자원을 제한하고 한꺼번에 다루는 리눅스 커널 기능입니다. v1 은 컨트롤러마다 계층이 따로 있어서 /sys/fs/cgroup/memory/..., /sys/fs/cgroup/systemd/... 처럼 갈라집니다. v2 는 이걸 하나로 합쳤습니다(unified). 디렉토리 구조가 바뀐다는 뜻이라 v1 시절 경로를 그대로 들고 있으면 v2 에서는 그런 파일이 아예 없습니다.

빼내는 코드가 하드코딩해 둔 경로가 이겁니다.

/sys/fs/cgroup/systemd/system.slice/<서비스>.service/cgroup.procs

/sys/fs/cgroup/systemd/... 는 cgroup v1 전용 경로인데, 새로 올린 OS 는 cgroup v2 라 이 경로가 아예 없습니다.

$ stat -fc %T /sys/fs/cgroup/
cgroup2fs                       ← 이 시스템은 순수 cgroup v2

$ ls /sys/fs/cgroup/systemd/
No such file or directory       ← v1 호환 경로가 없음

이 함수는 파일 읽기에 실패해서 매번 조용히 빈 값으로 끝나고 있었습니다. 에러를 리턴하지도 전파하지도 않고 println 으로만 찍어서 이게 죽어 있다는 걸 아무도 몰랐습니다.

조용한 no-op이 만든 증상

빼내기가 계속 실패했다는 건 컨테이너를 만들 때마다 붙는 보조 프로세스가 전부 서비스 cgroup 안에 그대로 쌓여 왔다는 뜻이라, 서비스가 한 번 재시작되면 쌓인 것들이 같이 죽고 평소에도 프로세스가 계속 늘어나면서 동시 생성이 몰릴 때 문제가 터질 구간이 넓어졌습니다. 그러는 동안 겉으로 보이는 건 가끔 컨테이너 생성이 실패한다는 것뿐이었습니다.

해결 — cgroup 버전을 판별해서 분기

cgroup v1/v2 를 런타임에 판별해서 경로를 각각에 맞게 나눴습니다. 판별은 /sys/fs/cgroup/cgroup.controllers 파일이 있는지로 하고, v2 에만 있는 파일이라 이것만 봐도 갈립니다.

func IsCgroupV2() bool {
    _, err := os.Stat("/sys/fs/cgroup/cgroup.controllers")
    return err == nil
}

// 읽는 경로도, "빼낼" 대상 경로도 v1/v2로 분기
path := "/sys/fs/cgroup/systemd/system.slice/<svc>.service/cgroup.procs"
if IsCgroupV2() {
    path = "/sys/fs/cgroup/system.slice/<svc>.service/cgroup.procs"
}

v2 경로에는 systemd/ 한 칸이 빠지고, 컨트롤러별로 갈라져 있던 게 하나로 합쳐지면서 그 층이 사라졌기 때문입니다.

OS 업그레이드로 깨지는 건 코드가 아니라 전제

코드를 한 줄도 안 바꿔도 실행 환경의 세대가 바뀌면 그 코드가 기대하던 게 사라집니다. OS 를 올릴 때 겉으로 보이는 건 커널과 패키지 버전이지만 그 밑에서 cgroup 이 v1 에서 v2 로 넘어가는 게 이런 코드에는 훨씬 큰 변화였습니다.

드러나는 데 오래 걸린 건 에러를 안 올린 탓입니다. 파일 읽기가 실패했을 때 println 만 찍고 빈 값으로 넘어가니 호출한 쪽은 성공한 줄 알았고 몇 달 뒤에 간헐적인 컨테이너 생성 실패라는 다른 증상으로 드러났습니다.

OS 를 올린 뒤에 원인 모를 간헐 실패가 생기면 코드 diff 를 뒤지기 전에 커널 인터페이스 경로를 하드코딩한 데가 있는지부터 봅니다. stat -fc %T /sys/fs/cgroup/ 한 줄로 지금 시스템이 v1 인지 v2 인지 나오고 v1 경로를 쓰는 코드가 있으면 거기가 조용히 아무 일도 안 하고 있을 자리입니다.

  1. 불러오는 중