2P by GN⁺ | ★ favorite | 댓글 1개
  • 컨테이너에 CPU 제한을 걸어도 Go 런타임은 기본적으로 이를 알지 못해, 호스트 전체 코어 기준으로 스레드를 만들고 지연 시간을 키울 수 있음
  • Go GC는 대부분 애플리케이션과 동시에 돌지만, Sweep Termination과 Mark Termination에서 모든 고루틴을 멈추는 stop-the-world(STW) 구간이 필요함
  • Linux CFS는 코어 수를 초당 CPU 시간으로 나눠 배분하며, --cpus=4는 컨테이너에 매초 4초 분량의 CPU 시간을 주는 방식임
  • 16코어 호스트에서 4코어 제한 컨테이너를 실행하면 Go가 16개 OS 스레드에 고루틴을 올릴 수 있어, CPU quota 소진 뒤 STW가 길어질 수 있음
  • GOMAXPROCS를 컨테이너 CPU 제한에 맞추면 예시에서 GC 사이클은 2.5ms 미만에서 1ms 미만으로 줄고, STW는 약 26μs까지 감소함

컨테이너 CPU 제한과 Go 런타임의 불일치

  • 컨테이너에서 Go 애플리케이션을 실행할 때 CPU 제한은 호스트 CPU를 모두 소비하지 않도록 막는 장치임
  • 문제는 Go 런타임이 컨테이너의 CPU 제한을 기본적으로 인식하지 못한다는 점임
  • 이 불일치로 런타임은 실제 quota보다 많은 CPU를 쓸 수 있다고 보고, 높은 지연 시간으로 이어질 수 있음

Go GC에서 STW가 발생하는 지점

  • Go 가비지 컬렉터는 대부분의 시간 동안 애플리케이션과 동시 실행
  • 다만 GC 과정에는 모든 고루틴을 멈춰야 하는 두 구간이 있음
    • Mark Phase 전에 write barrier를 적용하기 위해 멈추는 단계가 Sweep Termination
    • Mark Phase 뒤 write barrier를 제거하기 위해 다시 멈추는 단계가 Mark Termination
  • STW 구간은 보통 수십 마이크로초 수준임
  • 예시 애플리케이션은 메모리를 많이 할당하는 단순 웹 애플리케이션이며, 소스 코드는 go-cfs-blog에 있음
  • 컨테이너는 4 CPU 제한으로 실행됨
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
  • runtime/trace 패키지로 trace를 수집하고 go tool trace로 분석할 수 있음
  • 이 실행에서 GC 사이클은 2.5ms 미만이었지만, 그중 거의 10%가 STW 구간이었음
  • 지연 시간에 민감한 애플리케이션에서는 이 정도 비중도 문제가 될 수 있음

Docker CPU 제한과 Linux CFS 동작 방식

  • Docker의 --cpus CPU 제한은 hard limit
  • --cpu-shares도 설정할 수 있지만, 호스트가 CPU 제약을 받을 때만 강제됨
    • 호스트에 여유가 있으면 컨테이너는 할당된 CPU 코어보다 더 많이 사용할 수 있음
    • 호스트가 제약 상태가 되면 애플리케이션은 제한을 받음
  • Linux Completely Fair Scheduler(CFS)는 Linux 2.6.23에서 도입됐고 Linux 6.6 전까지 기본 스케줄러였음
  • CFS는 proportional share scheduler로, 프로세스의 weight를 사용할 수 있는 CPU 코어 수에 비례해 둠
    • 4개 CPU 코어를 사용할 수 있는 프로세스의 weight는 4임
    • 2개 CPU 코어를 사용할 수 있는 프로세스의 weight는 2임
  • CFS는 CPU 시간을 쪼개 배분함
    • 4코어 시스템은 매초 4초 분량의 CPU 시간을 배분할 수 있음
    • 컨테이너에 CPU 코어 수를 할당한다는 것은 Linux 스케줄러에 n CPU만큼의 시간을 요청하는 것과 같음
    • --cpus=4는 컨테이너가 매초 4초 분량의 CPU 시간을 받는다는 뜻임

왜 STW가 길어지는가

  • Go 런타임은 시작할 때 CPU 코어마다 하나의 OS 스레드를 만듦
  • 16코어 머신에서는 CGroup CPU 제한과 관계없이 16개 OS 스레드를 만들 수 있음
  • 런타임은 이 OS 스레드들 위에 고루틴을 스케줄함
  • 컨테이너 CPU 제한이 4코어여도 Go는 16개 OS 스레드 전체에 고루틴을 올릴 수 있음
  • 이 상태에서 런타임은 매초 16초 분량의 CPU 시간을 쓸 수 있다고 기대하게 됨
  • 긴 STW 시간은 Linux 스케줄러가 다시 실행해주기를 기다리는 스레드 위의 고루틴까지 모두 멈춰야 해서 발생함
  • 컨테이너가 CPU quota를 이미 사용한 뒤에는 해당 스레드들이 스케줄되지 않음

GOMAXPROCS를 CPU quota에 맞추기

  • Go는 GOMAXPROCS 환경 변수로 런타임이 사용할 CPU 스레드 수를 제한할 수 있음
  • CPU quota가 4인 컨테이너에서는 GOMAXPROCS=4를 함께 지정함
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
  • 같은 애플리케이션과 같은 부하에서 GOMAXPROCS를 CPU quota와 맞추자 GC 시간이 짧아짐
  • trace에서 GC 사이클은 1ms 미만으로 줄었고, STW 구간은 26μs였음
  • 이는 GOMAXPROCS 제한이 없을 때의 STW 시간과 비교해 약 1/10 수준임
  • GOMAXPROCS는 컨테이너가 사용할 수 있는 CPU 코어 수로 설정해야 함
    • fractional CPU를 할당할 때는 내림함
    • 1 CPU 미만을 할당할 때는 올림함
    • 계산식은 GOMAXPROCS=max(1, floor(CPUs))
  • Uber의 automaxprocs는 컨테이너 cgroups에서 이 값을 자동 계산하는 오픈소스 라이브러리임
  • Go 런타임에서 이를 기본 지원하기 위한 GitHub Issue가 열려 있음

컨테이너화된 Go 서비스에서 확인할 점

  • CPU 제한만 설정하면 충분하지 않고, Go 런타임이 그 제한을 반영하도록 GOMAXPROCS도 맞춰야 함
  • 직접 계산하기 어렵다면 automaxprocs 같은 라이브러리로 cgroups 기반 값을 자동 설정할 수 있음
  • 지연 시간에 민감한 Go 서비스는 GC trace에서 STW 시간을 확인해 CPU quota와 런타임 설정이 어긋나지 않는지 점검해야 함

댓글과 토론

Hacker News 의견들
  • 여러 언어에서 공통으로 보이는 문제는 애플리케이션이 /proc/cpuinfo를 보고 머신 코어 수를 감지한다는 점임
    하지만 Docker 컨테이너나 다른 컨테이너 기술 안에서는 이 파일이 컨테이너 호스트와 똑같이 보이고, 컨테이너에 실제로 몇 개만 할당됐든 모든 코어를 나열함
    한동안 Docker가 작업에 할당된 “Docker CPU”만 나열하는 가짜 /proc/cpuinfo를 만들 수 있지 않을까 생각했지만, 다시 생각해보니 여러 이유로 잘 안 될 것 같음

    • quota 기반 제한을 쓸 때 컨테이너는 호스트의 모든 CPU 코어를 사용할 수 있음
      제한되는 건 그 코어들을 얼마나 오래 사용할 수 있는지
      예외도 있으며 문서는 여기 있음: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
    • nproc만 쓰고, 다른 컨테이너에서도 bundle install -j $(nproc)처럼 쓰는 걸 봄
      이건 CPU 할당을 존중하므로 찾던 기능을 제공함
      임의의 애플리케이션이 가능할 때 nproc를 쓰는지는 모르겠음
      “현재 프로세스에 사용 가능한 처리 장치 수를 출력하며, 이는 온라인 프로세서 수보다 적을 수 있다. 이 정보를 얻을 수 없으면 설치된 프로세서 수를 출력한다”
      https://www.gnu.org/software/coreutils/manual/html_node/npro...
      https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
    • Go는 그렇게 하지 않음
      Go는 시작 시점에 CPU 마스크의 개수를 보고, 이후에는 다시 보지 않음
      Kubernetes에서는 프로세스 실행 중 보이는 CPU가 바뀔 수 있어서 문제가 됨
    • 가짜 /proc/cpuinfo는 이미 있음: https://github.com/lxc/lxcfs
      lxcfs는 cgroup 값을 추론해 /proc를 흉내 내는 FUSE 파일시스템으로, 애플리케이션과 라이브러리가 컨테이너 안에서 도는지 신경 쓰지 않아도 되게 해줌
      예를 들어 /proc/uptime은 호스트가 아니라 컨테이너의 가동 시간을 반영해야 하고, /proc/cpuinfo는 cpu.max와 cpuset.cpus의 조합 중 더 낮은 한계를 CPU 수로 반영함
      CPU 수 추론은 sched_getaffinity 시스템 호출로도 가능하며, 이 방식은 /proc/cpuinfo에 의존하지 않음
      그래서 쓰는 라이브러리에 따라 난처해질 수 있음
    • 이걸 보면 컨테이너는 엉성한 추상화이고 VMware가 기회를 놓쳤다는 결론이 듦
  • 이 설명은 미묘하게 틀렸음
    Docker 관점에서 CFS cgroup 확장에는 조정할 수 있는 손잡이가 여러 개 있음: cfs_quota_us, cfs_period_us(일반 기본값은 1초가 아니라 100ms), 그리고 shares임
    shares를 설정하면 가중치 기반 비례 스케줄링이 적용되지만, 경합이 있을 때만 의미가 있음
    앞의 두 값은 엄격한 quota를 강제함
    Docker의 --cpu 플래그 대신 --cpu-shares를 써서 대체로 쓸모없는 quota 강제를 피하는 편이 나음
    Linux 문서에 따르면 cpu.shares는 같은 계층의 각 그룹 가중치이고, cpu.cfs_period_us는 대역폭 판단을 위한 스케줄러 주기이며 기본값은 100000us 또는 100ms임
    cpu.cfs_quota_us는 각 cfs_period_us 동안 현재 그룹이 실행될 수 있는 최대 시간이고, 이 값은 시스템 전체 CPU에 걸친 합산 시간이라 CPU 2개를 완전히 쓰게 하려면 cfs_period_us의 두 배로 설정해야 함

    • “Docker의 --cpu 플래그를 쓰지 말고 대신…”이라는 표현은 별다른 단서 없이 너무 강함
      절대 “대체로 쓸모없다”고 볼 수 없음
      shares와 quota는 서로 다른 사용 사례를 위한 것이니, 자기 사용 사례를 이해하고 그에 맞게 고르면 됨
    • 한 가지 주의점은 --cpu를 쓰면 애플리케이션이 이를 감지할 수 있다는 것임
      아마 cpuset을 쓰기 때문으로 보임
      quota를 쓰면 감지하지 못해서 필요 이상으로 많은 스레드가 생길 가능성이 큼
    • 블로그 작성자인데 피드백 고마움
      이 부분을 더 명확히 해보겠음
      증상은 이런 식으로 나타난다고 생각하지만, 표현을 더 분명히 해야겠음
    • Kubernetes를 쓰는 사람들은 이런 설정을 직접 조정하거나 바꾸지 않음
      애플리케이션이 올바르게 동작해야 함
  • CPU limits 대신 CPU reservations를 쓰면 이런 조정은 필요 없음: https://home.robusta.dev/blog/stop-using-cpu-limits
    CPU reservations도 사실상 limits인데, 암묵적인 한계이자 보장으로 선언되는 것임
    그러니 Go 런타임이 사용 가능한 모든 CPU를 쓰게 두고, CPU 경합이 생기면 Linux 스케줄러가 선언된 reservations에 따라 제한하게 두면 됨

    • limits를 설정하는 이유는 어떤 파드가 다른 파드에 영향을 줄까 봐서가 아님
      보장되지 않은 초과 CPU를 쓸 수 있는 상태에 익숙해지고 싶지 않아서임
      노드에 다른 파드가 점점 차면 방금 전까지 잘 돌던 파드가 갑자기 느려질 수 있음
      limits를 쓰면 같은 동작을 시뮬레이션하고, 올바른 용량 계획으로 대비할 수 있음
      유일한 방법은 아니지만 가장 단순한 방법임
    • 128코어 구성에서 몇 가지를 돌리는데, CPU limits를 request보다 훨씬 높게 잡되 뭔가가 폭주하지 않도록 여전히 설정함
      이 논의가 더 궁금하지만, 링크한 글은 CPU를 모든 파드에 보장하려면 limit가 필요하다고 사람들이 생각한다는 내용만 다루는 것 같음
    • Kubernetes 커뮤니티에서는 이 논의를 격주로 하는 느낌임
      이 글이 그 자체로 틀린 건 아니고 대체로 콘텐츠 마케팅에 가깝지만, 단정이 넓고 limits를 설정할 좋은 이유를 여럿 무시함
      같은 곳의 글 중에는 그냥 틀린 것도 있음: https://home.robusta.dev/blog/containers-dont-use-chroot
      작은 이득밖에 없는데 모든 버스트 용량을 써버리는 워크로드도 있고, 지정된 시간 안에 끝날 cronjob보다 HTTP 서버의 버스트 용량을 우선시해야 할 때도 있음
      개발자들이 앱 요구량이 커졌는데 requests를 업데이트하지 않다가, 여유 CPU 시간이 갑자기 부족해지자 장애가 난 경우도 있었음
    • reservations는 limits가 아니라 최소 보장 CPU 사용량 제약
      이론적으로는 최소 보장 자원이지만, 같은 호스트에서 바쁜 컨테이너들이 함께 돌면 꼬리 지연 시간과 평균 지연 시간이 비정상적으로 증가할 수 있음
      4코어 EC2 인스턴스에서 CPU 사용률 50%일 때와 90%일 때의 지연 시간은 꽤 다름
      reservations에서도 비슷하게, 각 컨테이너가 자기 reservation을 보장받더라도 같은 호스트의 다른 바쁜 프로세스 때문에 상대적인 CPU 사용률이 매우 높아짐
    • 흥미롭지만 메모리에는 해당하지 않는 것 아닌가?
      OOMKiller가 잡아갈 수 있음
      CPU와 메모리 limits가 모두 없으면 Guaranteed QoS 클래스를 얻을 수 없으니, 어느 시점에 파드가 축출될 수도 있음
  • 컨테이너와 cgroup을 쓰면서 CFS 스케줄러에 여러 번 당했음
    새 스케줄러가 뭔지 궁금함
    여기서 프로덕션 클러스터에 써본 사람이 있나?
    이제 거의 20년째 코어를 낭비하고 있음: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf

    • 여기서 문제는 스케줄러가 아님
      컨테이너가 자원 제한을 걸었는데, 컨테이너 안 프로세스인 Go가 사용 가능한 병렬성 양을 계산할 때 그 제한에 쓰인 운영체제 기능을 확인하지 않는 게 문제임
    • https://kernelnewbies.org/Linux_6.6#New_task_scheduler:_EEVD...
  • GOMAXPROCS 외에도 최근 Go 릴리스에는 GOMEMLIMIT가 있음
    https://github.com/KimMachineGun/automemlimit를 쓰면 https://github.com/uber-go/automaxprocs와 비슷하게 이 한계를 자동 설정할 수 있음

  • 작년에 이전 직장에서 플랫폼 엔지니어로 온프레미스 Kubernetes 클러스터와 CI/CD 파이프라인 인프라를 관리하다가 이걸 발견했음
    실제 CPU와 할당 CPU의 불일치가 특히 CPU 스로틀링 같은 문제를 일으키는 건 봤지만, 클러스터의 모든 Go 배포에 영향을 주는 확장 가능한 해법을 찾기 어려웠음
    수백 개 프로젝트의 모든 개발자에게 autoprocs 의존성을 넣게 하는 건 선택지가 아니었음
    대안으로 모든 CPU request/limit를 정수로 맞추고 Kubernetes 매니페스트에서 그 값을 GOMAXPROCS 환경 변수에 넣는 방식도 번거롭고 실현 가능하지 않았음
    결국 다중 스레드를 많이 쓰는 일부 애플리케이션에만 GOMAXPROCS 변수를 적용해 개선을 얻었지만, 프로젝트마다 CPU 요구량 변동이 큰 마이크로서비스 아키텍처의 모든 배포에 적용 가능한 해법은 아직 못 찾았음

    • 여기에 하나의 정답은 없음
      GOMAXPROCS를 제한하면 프로세스에 트래픽이 몰리고 큐잉이 단순할 때 심각한 지연 시간 문제가 생길 수 있음
      평균적으로 프로세스가 얼마나 많은 시간을 쓸지에 대한 생각과 관계없이, 하드웨어가 제공하는 값으로 GOMAXPROCS를 설정하는 게 실제로는 가장 나음
    • 모든 파드 컨테이너에 GOMAXPROCS를 주입하는 mutating webhook을 정의할 수 있음
  • Docker나 Go에 익숙하지 않은 입장에서, 이 동작이 의도된 것인지 궁금함
    Go 팀이 CGroups limit를 인식하게 만들 수 있나?
    다른 런타임도 비슷하게 동작하나?

    • .NET도 이 문제를 처리해야 했던 것으로 꽤 확신하고, Java도 문제가 있었거나 아직 있는 것으로 기억함
      아니면 containerd 같은 런타임을 말한 건가?
    • JVM에서도 같은 문제를 겪었음
      Scala에서였음
  • 일시 정지를 더 짧게 만드는 GC 기법도 있음
    예를 들어 일시 정지 때 할 일을 동시에 수행한 뒤 안전 지점에서 다시 반복하는 방식임
    기대는 동시 작업 덕분에 안전 지점 작업이 “할 일이 없다”는 간단한 확인으로 바뀌는 것임
    작업을 두 배로 하면 GC 처리량은 나빠질 수 있음

  • 이 글은 컨테이너를 말하고 있지만, 문제는 Go가 기대보다 적은 CPU 시간만 접근할 수 있을 때는 언제나 생기는 것 같음
    CPU를 쓰는 다른 프로세스가 있는 시스템에서 Go를 실행해도 같은 일이 벌어지지 않나?
    심지어 Go 프로그램 두 개를 동시에 실행하기만 해도 그렇지 않을까?