성능 가이드라인
GitLab v19.2요약
이 문서는 GitLab의 우수하고 일관된 성능을 보장하기 위한 다양한 가이드라인을 설명합니다. 성능 문제를 해결하는 과정은 대략 다음과 같습니다. 타이밍을 제공할 때는 다음을 함께 제공해야 합니다. 그래프 스크린샷을 제공할 때는 X축과 Y축 그리고 범례가 명확히 보이도록 해야 합니다.
이 문서는 GitLab의 우수하고 일관된 성능을 보장하기 위한 다양한 가이드라인을 설명합니다.
성능 문서#
- 일반:
- 백엔드:
- 데이터베이스:
- 프론트엔드:
- E2E:
- 모니터링 및 개요:
- GitLab Self-Managed 관리 및 고객 중심:
워크플로#
성능 문제를 해결하는 과정은 대략 다음과 같습니다.
- 어딘가에(예: GitLab CE 이슈 트래커) 이슈가 열려 있는지 확인하고, 없다면 새로 생성합니다. 예시는 #15607을 참고하세요.
- GitLab.com과 같은 프로덕션 환경에서 코드의 성능을 측정합니다(아래 도구 섹션 참고). 성능은 최소한 24시간에 걸쳐 측정해야 합니다.
- 측정 기간을 기반으로 얻은 결과(그래프 스크린샷, 타이밍 등)를 1단계에서 언급한 이슈에 추가합니다.
- 문제를 해결합니다.
- 머지 리퀘스트를 생성하고 "Performance" 레이블을 지정한 뒤 성능 리뷰 프로세스를 따릅니다.
- 변경 사항이 배포된 후에는, 변경 사항이 프로덕션 환경에 어떤 영향을 미치는지 확인하기 위해 다시 최소 24시간 동안 측정합니다.
- 완료될 때까지 반복합니다.
타이밍을 제공할 때는 다음을 함께 제공해야 합니다.
- 95번째 백분위수
- 99번째 백분위수
- 평균
그래프 스크린샷을 제공할 때는 X축과 Y축 그리고 범례가 명확히 보이도록 해야 합니다. GitLab.com 자체 모니터링 도구에 접근할 수 있다면 관련 그래프/대시보드에 대한 링크도 함께 제공해야 합니다.
도구#
GitLab은 성능과 가용성을 개선하는 데 도움이 되는 기본 제공 도구들을 제공합니다.
- 프로파일링.
- 분산 추적
- GitLab 성능 모니터링.
N+1회귀를 방지하기 위한 QueryRecorder.- 장애 시나리오 테스트를 위한 Chaos 엔드포인트. 주로 가용성 테스트를 위한 용도입니다.
GitLab 팀 멤버는 dashboards.gitlab.net에 위치한 GitLab.com의 성능 모니터링 시스템을 사용할 수 있으며, 이를 위해서는 @gitlab.com 이메일 주소로 로그인해야 합니다. GitLab 팀 멤버가 아닌 경우 자체 Prometheus 및 Grafana 스택을 구성하는 것을 권장합니다.
벤치마크#
벤치마크는 거의 항상 쓸모가 없습니다. 벤치마크는 일반적으로 작은 코드 조각만 격리된 상태에서 테스트하며 최선의 경우 시나리오만 측정하는 경우가 많습니다. 게다가 라이브러리(예: Gem)에 대한 벤치마크는 해당 라이브러리에 유리하게 편향되는 경향이 있습니다. 어차피 저자가 경쟁 제품보다 성능이 떨어진다는 것을 보여주는 벤치마크를 게시할 이유는 거의 없기 때문입니다.
벤치마크는 변경 사항의 영향에 대한 대략적인("대략"이라는 점을 강조합니다) 이해가 필요할 때만 실제로 유용합니다. 예를 들어 특정 메서드가 느린 경우, 변경 사항이 해당 메서드의 성능에 영향을 미치는지 확인하는 데 벤치마크를 사용할 수 있습니다. 그러나 벤치마크에서 변경 사항이 성능을 개선한다고 나타나더라도 프로덕션 환경에서도 성능이 개선된다는 보장은 없습니다.
벤치마크를 작성할 때는 거의 항상 benchmark-ips를 사용해야 합니다. 표준 라이브러리에 포함된 Ruby의 Benchmark 모듈은 단일 반복(Benchmark.bm 사용 시) 또는 두 번의 반복(Benchmark.bmbm 사용 시)만 실행하므로 거의 유용하지 않습니다. 이렇게 적은 횟수만 반복하면 백그라운드에서 재생되는 동영상 스트리밍과 같은 외부 요인이 벤치마크 통계를 매우 쉽게 왜곡할 수 있습니다.
Benchmark 모듈의 또 다른 문제는 반복 횟수가 아니라 타이밍을 표시한다는 점입니다. 이는 코드 조각이 매우 짧은 시간에 완료되는 경우 특정 변경 전후의 타이밍을 비교하기가 매우 어려울 수 있음을 의미합니다. 이는 결과적으로 다음과 같은 패턴으로 이어집니다.
Benchmark.bmbm(10) do |bench|
bench.report 'do something' do
100.times do
... work here ...
end
end
end
그러나 이는 다음과 같은 질문으로 이어집니다. 의미 있는 통계를 얻으려면 몇 번의 반복을 실행해야 할까요?
benchmark-ips gem은 이 모든 것을 포함하여 그 이상을 처리합니다. 따라서 Benchmark 모듈 대신 이 gem을 사용해야 합니다.
GitLab의 Gemfile에는 benchmark-memory gem도 포함되어 있으며, 이는 benchmark 및 benchmark-ips gem과 유사하게 동작합니다. 다만 benchmark-memory는 벤치마크 동안 할당되고 유지된 메모리 크기, 객체, 문자열을 반환합니다.
요약하면 다음과 같습니다.
- 인터넷에서 찾은 벤치마크를 신뢰하지 마세요.
- 벤치마크만을 근거로 주장하지 말고, 항상 프로덕션에서 측정하여 결과를 확인하세요.
- X가 Y보다 N배 빠르다는 것은 프로덕션 환경에 어떤 영향을 미치는지 모른다면 무의미합니다.
- 프로덕션 환경은 (성능 모니터링 시스템이 올바르게 설정되어 있지 않은 경우가 아니라면) 항상 진실을 말해주는 유일한 벤치마크입니다.
- 반드시 벤치마크를 작성해야 한다면 Ruby의
Benchmark모듈 대신 benchmark-ips Gem을 사용하세요.
Stackprof를 이용한 프로파일링#
프로파일링은 일정한 간격으로 프로세스 상태의 스냅샷을 수집하여 프로세스에서 시간이 어디에 소비되는지 확인할 수 있게 해줍니다. Stackprof gem이 GitLab에 포함되어 있어 어떤 코드가 CPU에서 실행되고 있는지 상세하게 프로파일링할 수 있습니다.
애플리케이션을 프로파일링하면 성능이 변경됩니다. 프로파일링 전략에 따라 오버헤드가 다릅니다. Stackprof는 샘플링 프로파일러입니다. 실행 중인 스레드에서 설정 가능한 주기(예: 100 hz, 즉 초당 100개의 스택)로 스택 트레이스를 샘플링합니다. 이 유형의 프로파일링은 (0은 아니지만) 상당히 낮은 오버헤드를 가지며 일반적으로 프로덕션에서 안전하다고 여겨집니다.
프로파일러는 대표성이 없는 환경에서 실행되더라도 개발 과정에서 매우 유용한 도구가 될 수 있습니다. 특히 어떤 메서드가 여러 번 실행되거나 실행하는 데 오랜 시간이 걸린다고 해서 반드시 문제가 되는 것은 아닙니다. 프로파일은 애플리케이션에서 무슨 일이 일어나고 있는지 더 잘 이해하는 데 사용할 수 있는 도구이며, 그 정보를 현명하게 활용하는 것은 여러분에게 달려 있습니다!
Stackprof로 프로파일을 생성하는 방법에는 여러 가지가 있습니다.
코드 블록 감싸기#
특정 코드 블록을 프로파일링하려면 해당 블록을 Stackprof.run 호출로 감쌀 수 있습니다.
StackProf.run(mode: :wall, out: 'tmp/stackprof-profiling.dump') do
#...
end
이렇게 하면 읽을 수 있는 .dump 파일이 생성됩니다. 사용 가능한 모든 옵션은 Stackprof 문서를 참고하세요.
성능 표시줄#
성능 표시줄을 사용하면 Stackprof로 요청을 프로파일링하고 그 결과를 Speedscope 플레임그래프로 즉시 출력할 수 있습니다.
Stackprof를 이용한 RSpec 프로파일링#
스펙에서 프로파일을 생성하려면 문제가 되는 코드 경로를 실행하는 스펙을 식별하거나 생성한 다음, bin/rspec-stackprof 헬퍼를 사용하여 실행합니다. 예를 들면 다음과 같습니다.
$ bin/rspec-stackprof --limit=10 spec/policies/project_policy_spec.rb
8/8 |====== 100 ======>| Time: 00:00:18
Finished in 18.19 seconds (files took 4.8 seconds to load)
8 examples, 0 failures
==================================
Mode: wall(1000)
Samples: 17033 (5.59% miss rate)
GC: 1901 (11.16%)
==================================
TOTAL (pct) SAMPLES (pct) FRAME
6000 (35.2%) 2566 (15.1%) Sprockets::Cache::FileStore#get
2018 (11.8%) 888 (5.2%) ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#exec_no_cache
1338 (7.9%) 640 (3.8%) ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements#execute
3125 (18.3%) 394 (2.3%) Sprockets::Cache::FileStore#safe_open
913 (5.4%) 301 (1.8%) ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#exec_cache
288 (1.7%) 288 (1.7%) ActiveRecord::Attribute#initialize
246 (1.4%) 246 (1.4%) Sprockets::Cache::FileStore#safe_stat
295 (1.7%) 193 (1.1%) block (2 levels) in class_attribute
187 (1.1%) 187 (1.1%) block (4 levels) in class_attribute
RSpec가 일반적으로 받는 인수를 전달하여 실행되는 스펙을 제한할 수 있습니다.
프로덕션에서 Stackprof 사용#
Stackprof는 프로덕션 워크로드를 프로파일링하는 데에도 사용할 수 있습니다.
Ruby 프로세스에 대한 프로덕션 프로파일링을 활성화하려면 STACKPROF_ENABLED 환경 변수를 true로 설정합니다.
다음 구성 옵션을 설정할 수 있습니다.
STACKPROF_ENABLED: SIGUSR2 시그널에 대한 Stackprof 시그널 핸들러를 활성화합니다. 기본값은false입니다.STACKPROF_MODE: 샘플링 모드를 참고하세요. 기본값은cpu입니다.STACKPROF_INTERVAL: 샘플링 간격입니다. 단위 의미는STACKPROF_MODE에 따라 다릅니다.object모드에서는 이벤트당 간격(n번째 이벤트마다 샘플링)이며 기본값은100입니다.cpu와 같은 다른 모드에서는 주파수 간격이며 기본값은10100μs(99 hz)입니다.STACKPROF_FILE_PREFIX: 프로파일이 저장되는 파일 경로 접두사입니다. 기본값은$TMPDIR(주로/tmp에 해당)입니다.STACKPROF_TIMEOUT_S: 초 단위의 프로파일링 타임아웃입니다. 이 시간이 경과하면 프로파일링이 자동으로 중지됩니다. 기본값은30입니다.STACKPROF_RAW: 원시 샘플을 수집할지 집계만 수집할지를 결정합니다. 플레임그래프를 생성하려면 원시 샘플이 필요하지만, 메모리와 디스크 오버헤드가 더 높습니다. 기본값은true입니다.
활성화되면 Ruby 프로세스에 SIGUSR2 시그널을 보내 프로파일링을 트리거할 수 있습니다. 프로세스가 스택 샘플링을 시작합니다. 다시 SIGUSR2를 보내면 프로파일링을 중지할 수 있습니다. 또는 타임아웃이 지나면 자동으로 중지됩니다.
프로파일링이 중지되면 프로파일이 $STACKPROF_FILE_PREFIX/stackprof.$PID.$RAND.profile에 디스크로 기록됩니다. 이후 Stackprof 프로파일 읽기 섹션에서 설명한 대로 stackprof 명령줄 도구를 통해 더 자세히 검사할 수 있습니다.
현재 지원되는 프로파일링 대상은 다음과 같습니다.
- Puma 워커
- Sidekiq
Puma 마스터 프로세스는 지원되지 않습니다. 마스터 프로세스에 SIGUSR2를 보내면 재시작이 트리거됩니다. Puma의 경우 시그널을 Puma 워커에만 보내도록 주의해야 합니다.
이는 pkill -USR2 puma:를 통해 수행할 수 있습니다. :는 puma 4.3.3.gitlab.2 ...(마스터 프로세스)와 puma: cluster worker 0: ...(워커 프로세스)를 구분하여 후자를 선택합니다.
Sidekiq의 경우 pkill -USR2 bin/sidekiq-cluster로 sidekiq-cluster 프로세스에 시그널을 보낼 수 있으며, 이 시그널은 모든 Sidekiq 자식 프로세스로 전달됩니다. 또는 관심 있는 특정 PID를 선택할 수도 있습니다.
Stackprof 프로파일 읽기#
출력은 기본적으로 Samples 열을 기준으로 정렬됩니다. 이는 해당 메서드가 현재 실행 중인 메서드일 때 채취된 샘플 수입니다. Total 열은 해당 메서드(또는 그 메서드가 호출하는 메서드 중 하나)가 실행될 때 채취된 샘플 수를 보여줍니다.
호출 스택의 그래픽 뷰를 생성하려면 다음과 같이 합니다.
stackprof tmp/project_policy_spec.rb.dump --graphviz > project_policy_spec.dot
dot -Tsvg project_policy_spec.dot > project_policy_spec.svg
프로파일을 KCachegrind에서 로드하려면 다음과 같이 합니다.
stackprof tmp/project_policy_spec.rb.dump --callgrind > project_policy_spec.callgrind
kcachegrind project_policy_spec.callgrind # Linux
qcachegrind project_policy_spec.callgrind # Mac
결과 플레임그래프를 생성하고 볼 수도 있습니다. bin/rspec-stackprof가 생성하는 플레임그래프를 보려면 bin/rspec-stackprof를 실행할 때 --raw=true 옵션을 추가해야 합니다.
출력 파일 크기에 따라 생성하는 데 시간이 걸릴 수 있습니다.
# Generate
stackprof --flamegraph tmp/group_member_policy_spec.rb.dump > group_member_policy_spec.flame
# View
stackprof --flamegraph-viewer=group_member_policy_spec.flame
플레임그래프를 SVG 파일로 내보내려면 Brendan Gregg의 FlameGraph 도구를 사용합니다.
stackprof --stackcollapse /tmp/group_member_policy_spec.rb.dump | flamegraph.pl > flamegraph.svg
Speedscope를 통해 플레임그래프를 볼 수도 있습니다. 성능 표시줄을 사용할 때와 코드 블록을 프로파일링할 때 이를 수행할 수 있습니다. 이 옵션은 bin/rspec-stackprof에서는 지원되지 않습니다.
--method method_name을 사용하여 특정 메서드를 프로파일링할 수 있습니다.
$ stackprof tmp/project_policy_spec.rb.dump --method access_allowed_to
ProjectPolicy#access_allowed_to? (/Users/royzwambag/work/gitlab-development-kit/gitlab/app/policies/project_policy.rb:793)
samples: 0 self (0.0%) / 578 total (0.7%)
callers:
397 ( 68.7%) block (2 levels) in <class:ProjectPolicy>
95 ( 16.4%) block in <class:ProjectPolicy>
86 ( 14.9%) block in <class:ProjectPolicy>
callees (578 total):
399 ( 69.0%) ProjectPolicy#team_access_level
141 ( 24.4%) Project::GeneratedAssociationMethods#project_feature
30 ( 5.2%) DeclarativePolicy::Base#can?
8 ( 1.4%) Featurable#access_level
code:
| 793 | def access_allowed_to?(feature)
141 (0.2%) | 794 | return false unless project.project_feature
| 795 |
8 (0.0%) | 796 | case project.project_feature.access_level(feature)
| 797 | when ProjectFeature::DISABLED
| 798 | false
| 799 | when ProjectFeature::PRIVATE
429 (0.5%) | 800 | can?(:read_all_resources) || team_access_level >= ProjectFeature.required_minimum_access_level(feature)
| 801 | else
Stackprof를 사용하여 스펙을 프로파일링하면 프로파일에 테스트 스위트가 수행한 작업과 애플리케이션 코드가 모두 포함됩니다. 따라서 이러한 프로파일을 사용하여 느린 테스트를 조사할 수도 있습니다. 다만 (이 예시처럼) 더 작은 실행의 경우, 테스트 스위트를 설정하는 비용이 지배적인 경향이 있습니다.
RSpec 프로파일링#
GitLab 개발 환경에는 스펙 실행 시간에 대한 데이터를 수집하는 데 사용되는 rspec_profiling gem도 포함되어 있습니다. 이는 테스트 스위트 자체의 성능을 분석하거나, 시간이 지남에 따라 스펙의 성능이 어떻게 변화했는지 확인하는 데 유용합니다.
로컬 환경에서 프로파일링을 활성화하려면 다음을 실행합니다.
export RSPEC_PROFILING=yes
이렇게 하면 rspec/profiling/에 CSV 파일이 생성되며, 테스트 실행마다 타임스탬프가 지정된 파일이 하나씩 생성됩니다. 각 파일에는 해당 세션의 모든 스펙에 대한 타이밍, 쿼리 수, 쿼리 시간, 요청 수, 요청 시간, 기능 카테고리가 기록됩니다.
다른 출력 디렉터리를 사용하려면 RSPEC_PROFILING_FOLDER_PATH를 설정합니다.
전역 런타임 메트릭#
모든 테스트 실행 메트릭은 ClickHouse 데이터베이스 인스턴스로도 내보내지며 Grafana 대시보드를 사용하여 시각화됩니다.
Test Runtime Overview 대시보드는 가장 느린 스펙 파일과 특정 스펙 파일에 대한 시간 경과에 따른 런타임 추이를 보여줍니다.
메모리 최적화#
메모리 문제를 추적하기 위해 여러 가지 기법을 종종 조합하여 사용할 수 있습니다.
- 코드를 그대로 두고 프로파일러로 감쌉니다.
- 요청 및 서비스에 대한 메모리 할당 카운터를 사용합니다.
- 문제가 될 것으로 의심되는 코드의 여러 부분을 비활성화/활성화하면서 프로세스의 메모리 사용량을 모니터링합니다.
메모리 할당#
GitLab에 함께 제공되는 Ruby에는 메모리 할당 추적을 허용하는 특수 패치가 포함되어 있습니다. 이 패치는 Omnibus, CNG, GitLab CI, GCK에서 기본적으로 사용할 수 있으며, GDK에서는 추가로 활성화할 수 있습니다.
이 패치는 주어진 코드 경로의 메모리 사용 효율을 더 쉽게 이해할 수 있도록 다음 메트릭을 제공합니다.
mem_total_bytes: 기존 객체 슬롯에 새 객체가 할당되면서 소비된 바이트 수와 대형 객체에 대해 추가로 할당된 메모리(즉,mem_bytes + slot_size * mem_objects)를 합한 값입니다.mem_bytes: 기존 객체 슬롯에 들어가지 못한 객체에 대해malloc으로 할당된 바이트 수입니다.mem_objects: 할당된 객체 수입니다.mem_mallocs:malloc호출 수입니다.
할당된 객체 및 바이트 수는 GC 사이클이 얼마나 자주 발생하는지에 영향을 줍니다. 객체 할당이 적을수록 애플리케이션의 응답성이 크게 향상됩니다.
웹 서버 요청은 100k mem_objects 및 100M mem_bytes를 초과하여 할당하지 않는 것이 권장됩니다. 현재 사용량은 GitLab.com에서 확인할 수 있습니다.
자신의 코드의 메모리 부담 확인#
자신의 코드를 측정하는 방법에는 두 가지가 있습니다.
- 메모리 할당 카운터가 포함된
api_json.log,development_json.log,sidekiq.log를 검토합니다. - 주어진 코드 블록에 대해
Gitlab::Memory::Instrumentation.with_memory_allocations를 사용하고 이를 로깅합니다.
{"time":"2021-02-15T11:20:40.821Z","severity":"INFO","duration_s":0.27412,"db_duration_s":0.05755,"view_duration_s":0.21657,"status":201,"method":"POST","path":"/api/v4/projects/user/1","mem_objects":86705,"mem_bytes":4277179,"mem_mallocs":22693,"correlation_id":"...}
다양한 할당 유형#
mem_* 값은 Ruby에서 객체와 메모리가 할당되는 방식의 서로 다른 측면을 나타냅니다.
-
다음 예시는 약
1000개의mem_objects를 생성합니다. 문자열은 동결될 수 있어 기저의 문자열 객체는 동일하게 유지되지만, 이 문자열에 대한 참조 1000개를 여전히 할당해야 하기 때문입니다.Gitlab::Memory::Instrumentation.with_memory_allocations do 1_000.times { '0123456789' } end => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0} -
다음 예시는 문자열이 동적으로 생성되므로 약
1000개의mem_objects를 생성합니다. 각 문자열은 40바이트의 Ruby 슬롯에 들어가므로 추가 메모리를 할당하지 않습니다.Gitlab::Memory::Instrumentation.with_memory_allocations do s = '0' 1_000.times { s * 23 } end => {:mem_objects=>1002, :mem_bytes=>0, :mem_mallocs=>0} -
다음 예시는 문자열이 동적으로 생성되므로 약
1000개의mem_objects를 생성합니다. 각 문자열은 40바이트의 Ruby 슬롯보다 크므로 추가 메모리를 할당합니다.Gitlab::Memory::Instrumentation.with_memory_allocations do s = '0' 1_000.times { s * 24 } end => {:mem_objects=>1002, :mem_bytes=>32000, :mem_mallocs=>1000} -
다음 예시는 40 kB가 넘는 데이터를 할당하고 단 한 번의 메모리 할당만 수행합니다. 기존 객체는 이후 반복에서 재할당/크기 조정됩니다.
Gitlab::Memory::Instrumentation.with_memory_allocations do str = '' append = '0123456789012345678901234567890123456789' # 40 bytes 1_000.times { str.concat(append) } end => {:mem_objects=>3, :mem_bytes=>49152, :mem_mallocs=>1} -
다음 예시는 1천 개가 넘는 객체를 생성하고 1천 회가 넘는 할당을 수행하며, 매번 객체를 변경합니다. 이는 많은 데이터를 복사하고 많은 메모리 할당을 수행하게 되어(
mem_bytes카운터로 표현됨) 문자열을 이어붙이는 매우 비효율적인 방법임을 나타냅니다.Gitlab::Memory::Instrumentation.with_memory_allocations do str = '' append = '0123456789012345678901234567890123456789' # 40 bytes 1_000.times { str += append } end => {:mem_objects=>1003, :mem_bytes=>21968752, :mem_mallocs=>1000}
Memory Profiler 사용#
프로파일링에는 memory_profiler를 사용할 수 있습니다.
memory_profiler gem은 GitLab Gemfile에 이미 포함되어 있습니다. 현재 URL에 대해 성능 표시줄에서도 사용할 수 있습니다.
코드에서 메모리 프로파일러를 직접 사용하려면 require를 사용하여 추가합니다.
require 'memory_profiler'
report = MemoryProfiler.report do
# Code you want to profile
end
output = File.open('/tmp/profile.txt','w')
report.pretty_print(output)
보고서는 gem, 파일, 위치, 클래스별로 그룹화된 유지 및 할당 메모리를 보여줍니다. 메모리 프로파일러는 또한 문자열이 얼마나 자주 할당되고 유지되는지 보여주는 문자열 분석을 수행합니다.
유지 대 할당#
- 유지(Retained) 메모리: 코드 블록 실행으로 인해 유지되는 장기 메모리 사용량 및 객체 수입니다. 이는 메모리와 가비지 컬렉터에 직접적인 영향을 미칩니다.
- 할당(Allocated) 메모리: 코드 블록 동안의 모든 객체 할당 및 메모리 할당입니다. 이는 메모리에 미치는 영향은 미미할 수 있지만 성능에는 상당한 영향을 미칠 수 있습니다. 객체를 더 많이 할당할수록 더 많은 작업이 수행되고 애플리케이션이 느려집니다.
일반적인 규칙으로, 유지는 항상 할당보다 작거나 같습니다.
MRI 힙은 크기에 맞게 압축되지 않고 메모리가 단편화되므로 실제 RSS 비용은 항상 약간 더 높습니다.
Rbtrace#
메모리 사용량 증가의 원인 중 하나는 Ruby 메모리 단편화일 수 있습니다.
이를 진단하려면 Aaron Patterson의 이 게시물에서 설명한 대로 Ruby 힙을 시각화할 수 있습니다.
시작하려면 조사 중인 프로세스의 힙을 JSON 파일로 덤프해야 합니다.
탐색 중인 프로세스 내부에서 명령을 실행해야 하며, 이는 rbtrace로 수행할 수 있습니다. rbtrace는 GitLab Gemfile에 이미 포함되어 있으므로 require만 하면 됩니다. 환경 변수 ENABLE_RBTRACE=1을 설정한 상태로 웹서버나 Sidekiq를 실행하여 이를 달성할 수 있습니다.
힙 덤프를 얻으려면 다음과 같이 합니다.
bundle exec rbtrace -p -e 'File.open("heap.json", "wb") { |t| ObjectSpace.dump_all(output: t) }'
JSON을 얻은 후에는 Aaron이 제공한 스크립트 또는 유사한 스크립트를 사용하여 마침내 그림을 렌더링할 수 있습니다.
ruby heapviz.rb heap.json
단편화된 Ruby 힙 스냅샷은 다음과 같이 보일 수 있습니다.
메모리 단편화는 이 게시물에서 설명한 대로 GC 매개변수를 조정하여 줄일 수 있습니다. 이는 메모리 할당 및 GC 사이클의 전반적인 성능에 영향을 미칠 수 있으므로 트레이드오프로 간주해야 합니다.
Derailed Benchmarks#
derailed_benchmarks는 "Rails 또는 Ruby 앱을 벤치마킹하는 데 사용할 수 있는 일련의 도구"로 설명되는 gem입니다. 우리는 derailed_benchmarks를 Gemfile에 포함하고 있습니다.
우리는 test 스테이지가 있는 모든 파이프라인에서 memory-on-boot라는 잡으로 derailed exec perf:mem을 실행합니다. (예시 잡을 참고하세요..) 결과는 다음에서 확인할 수 있습니다.
- 머지 리퀘스트 개요 탭의 머지 리퀘스트 리포트 영역에 있는 메트릭 리포트 드롭다운 목록.
- 전체 리포트와 종속성 분석을 위한
memory-on-boot아티팩트.
derailed_benchmarks는 메모리를 조사하기 위한 다른 메서드도 제공합니다. 자세한 내용은 gem 문서를 참고하세요. 대부분의 메서드(derailed exec perf:*)는 production 환경에서 Rails 앱을 부팅하고 이에 대해 벤치마크를 실행하려고 시도합니다. 이는 GDK와 GCK 모두에서 가능합니다.
- GDK의 경우 gem 페이지의 지침을 따르세요. 오류를 피하려면 Redis 구성에 대해서도 유사하게 수행해야 합니다.
- GCK에는 기본적으로
production구성 섹션이 포함되어 있습니다.
변경 사항의 중요성#
성능 개선 작업을 할 때는 항상 "이 코드 조각의 성능을 개선하는 것이 얼마나 중요한가?"라는 질문을 스스로에게 던지는 것이 중요합니다. 모든 코드 조각이 똑같이 중요한 것은 아니며, 아주 극소수의 사용자에게만 영향을 미치는 것을 개선하려고 일주일을 소비하는 것은 낭비입니다. 예를 들어, 어떤 메서드에서 10밀리초를 짜내려고 일주일을 쓰는 것은 다른 곳에서 10초를 짜낼 수 있었을 때 시간 낭비입니다.
특정 코드 조각이 최적화할 가치가 있는지 판단하기 위해 따를 수 있는 명확한 단계 집합은 없습니다. 할 수 있는 것은 다음 두 가지뿐입니다.
- 코드가 무엇을 하는지, 어떻게 사용되는지, 몇 번 호출되는지, 그리고 전체 실행 시간(예: 웹 요청에 소비된 총 시간) 대비 얼마나 많은 시간이 소비되는지를 생각합니다.
- 다른 사람에게 (가급적 이슈 형태로) 물어봅니다.
그다지 중요하지 않거나 노력할 가치가 없는 변경의 예시는 다음과 같습니다.
- 큰따옴표를 작은따옴표로 바꾸기.
- 값 목록이 매우 작을 때 Array 사용을 Set으로 바꾸기.
- 둘 다 전체 실행 시간의 0.1%만 차지할 때 라이브러리 A를 라이브러리 B로 바꾸기.
- 모든 문자열에서
freeze호출하기(String Freezing 참고).
느린 작업 및 Sidekiq#
브랜치 병합과 같은 느린 작업이나 (외부 API 사용처럼) 오류가 발생하기 쉬운 작업은 가능한 한 웹 요청에서 직접 수행하는 대신 Sidekiq 워커에서 수행해야 합니다. 이는 다음과 같은 여러 이점을 가집니다.
- 오류가 발생해도 요청 완료를 막지 않습니다.
- 프로세스가 느려도 페이지 로딩 시간에 영향을 주지 않습니다.
- 실패한 경우 프로세스를 재시도할 수 있습니다(Sidekiq가 이를 자동으로 처리합니다).
- 웹 요청에서 코드를 분리함으로써 테스트와 유지 관리가 더 쉬워집니다.
특히 Git 작업을 다룰 때는 Sidekiq를 가능한 한 많이 사용하는 것이 중요합니다. 이러한 작업은 기저 스토리지 시스템의 성능에 따라 완료하는 데 상당한 시간이 걸릴 수 있기 때문입니다.
Git 작업#
불필요한 Git 작업을 실행하지 않도록 주의해야 합니다. 예를 들어 Repository#branch_names를 사용하여 브랜치 이름 목록을 가져오는 작업은 리포지토리가 존재하는지 명시적으로 확인하지 않고도 수행할 수 있습니다. 다시 말해, 다음과 같이 하는 대신:
if repository.exists?
repository.branch_names.each do |name|
...
end
end
다음과 같이 작성하면 됩니다.
repository.branch_names.each do |name|
...
end
캐싱#
종종 동일한 결과를 반환하는 작업, 특히 Git 작업은 Redis를 사용하여 캐싱해야 합니다. Redis에 데이터를 캐싱할 때는 필요할 때마다 캐시가 플러시되도록 해야 합니다. 예를 들어 태그 목록에 대한 캐시는 새 태그가 푸시되거나 태그가 제거될 때마다 플러시되어야 합니다.
리포지토리에 대한 캐시 만료 코드를 추가할 때, 이 코드는 Repository 클래스에 있는 before/after 훅 중 하나에 배치해야 합니다. 예를 들어 리포지토리를 가져온 후 캐시를 플러시해야 한다면 이 코드는 Repository#after_import에 추가해야 합니다. 이렇게 하면 캐시 로직이 다른 클래스로 새어 나가지 않고 Repository 클래스 내에 유지됩니다.
데이터를 캐싱할 때는 결과를 인스턴스 변수에 메모이제이션도 해야 합니다. Redis에서 데이터를 가져오는 것이 원시 Git 작업보다 훨씬 빠르긴 하지만 여전히 오버헤드가 있습니다. 결과를 인스턴스 변수에 캐싱하면 동일한 메서드를 반복 호출할 때 매번 Redis에서 데이터를 가져오지 않습니다. 캐싱된 데이터를 인스턴스 변수에 메모이제이션할 때는 캐시를 플러시할 때 인스턴스 변수도 재설정해야 합니다. 예시는 다음과 같습니다.
def first_branch
@first_branch ||= cache.fetch(:first_branch) { branches.first }
end
def expire_first_branch_cache
cache.expire(:first_branch)
@first_branch = nil
end
String Freezing#
최근 Ruby 버전에서는 String에 .freeze를 호출하면 한 번만 할당되고 재사용됩니다. 예를 들어 Ruby 2.3 이상에서는 다음 코드가 "foo" String을 한 번만 할당합니다.
10.times do
'foo'.freeze
end
String의 크기와 (.freeze 호출이 추가되기 전에) 할당되는 빈도에 따라 이는 속도를 높일 수 있지만 보장되지는 않습니다.
문자열을 동결하면 메모리가 절약됩니다. 할당된 모든 문자열은 최소한 하나의 RVALUE_SIZE 바이트(x64에서 40바이트)의 메모리를 사용하기 때문입니다.
메모리 프로파일러를 사용하여 어떤 문자열이 자주 할당되며 잠재적으로 .freeze의 이점을 얻을 수 있는지 확인할 수 있습니다.
문자열 할당을 줄이고 성능을 개선하려면 모든 Ruby 파일에 다음 헤더를 추가하세요. RuboCop은 코드베이스 전반의 일관성을 유지하기 위해 이 헤더를 강제합니다.
# frozen_string_literal: true
이는 문자열을 조작할 수 있기를 기대하는 코드에서 테스트 실패를 일으킬 수 있습니다. dup을 사용하는 대신 단항 플러스를 사용하여 동결되지 않은 문자열을 얻으세요.
test = +"hello"
test += " world"
새 Ruby 파일을 추가할 때는 위 헤더를 추가할 수 있는지 확인하세요. 이를 생략하면 스타일 검사 실패로 이어질 수 있습니다.
Banzai 파이프라인 및 필터#
Banzai 필터 및 파이프라인을 작성하거나 업데이트할 때, 필터의 성능이 어떠한지, 그리고 전체 파이프라인 성능에 어떤 영향을 미칠 수 있는지 이해하기 어려울 수 있습니다.
벤치마크를 수행하려면 다음을 실행합니다.
bin/rake benchmark:banzai
이 명령은 다음과 같은 출력을 생성합니다.
--> Benchmarking Full, Wiki, and Plain pipelines
Calculating -------------------------------------
Full pipeline 1.000 i/100ms
Wiki pipeline 1.000 i/100ms
Plain pipeline 1.000 i/100ms
-------------------------------------------------
Full pipeline 3.357 (±29.8%) i/s - 31.000
Wiki pipeline 2.893 (±34.6%) i/s - 25.000 in 10.677014s
Plain pipeline 15.447 (±32.4%) i/s - 119.000
Comparison:
Plain pipeline: 15.4 i/s
Full pipeline: 3.4 i/s - 4.60x slower
Wiki pipeline: 2.9 i/s - 5.34x slower
.
--> Benchmarking FullPipeline filters
Calculating -------------------------------------
Markdown 24.000 i/100ms
Plantuml 8.000 i/100ms
SpacedLink 22.000 i/100ms
...
TaskList 49.000 i/100ms
InlineDiff 9.000 i/100ms
SetDirection 369.000 i/100ms
-------------------------------------------------
Markdown 237.796 (±16.4%) i/s - 2.304k
Plantuml 80.415 (±36.1%) i/s - 520.000
SpacedLink 168.188 (±10.1%) i/s - 1.672k
...
TaskList 101.145 (± 6.9%) i/s - 1.029k
InlineDiff 52.925 (±15.1%) i/s - 522.000
SetDirection 3.728k (±17.2%) i/s - 34.317k in 10.617882s
Comparison:
Suggestion: 739616.9 i/s
Kroki: 306449.0 i/s - 2.41x slower
InlineGrafanaMetrics: 156535.6 i/s - 4.72x slower
SetDirection: 3728.3 i/s - 198.38x slower
...
UserReference: 2.1 i/s - 360365.80x slower
ExternalLink: 1.6 i/s - 470400.67x slower
ProjectReference: 0.7 i/s - 1128756.09x slower
.
--> Benchmarking PlainMarkdownPipeline filters
Calculating -------------------------------------
Markdown 19.000 i/100ms
-------------------------------------------------
Markdown 241.476 (±15.3%) i/s - 2.356k
이를 통해 다양한 필터가 어떻게 수행되는지, 그리고 어떤 필터가 가장 느리게 수행될 수 있는지에 대한 아이디어를 얻을 수 있습니다.
테스트 데이터는 필터의 성능과 밀접한 관련이 있습니다. 테스트 데이터에 특정 필터를 명시적으로 트리거하는 것이 없다면, 그 필터는 매우 빠르게 실행되는 것처럼 보일 수 있습니다. spec/fixtures/markdown.md.erb 파일에 필터에 대한 관련 테스트 데이터가 있는지 확인하세요.
특정 필터 벤치마킹#
필터 이름을 환경 변수로 지정하여 특정 필터를 벤치마킹할 수 있습니다. 예를 들어 MarkdownFilter를 벤치마킹하려면 다음을 사용합니다.
FILTER=MarkdownFilter bin/rake benchmark:banzai
이는 다음 출력을 생성합니다.
--> Benchmarking MarkdownFilter for FullPipeline
Warming up --------------------------------------
Markdown 271.000 i/100ms
Calculating -------------------------------------
Markdown 2.584k (±16.5%) i/s - 23.848k in 10.042503s
파일 및 기타 데이터 소스에서 읽기#
Ruby는 파일 내용이나 일반적인 I/O 스트림을 다루는 여러 편의 함수를 제공합니다. IO.read 및 IO.readlines와 같은 함수는 데이터를 메모리로 쉽게 읽어들이지만, 데이터가 커지면 비효율적일 수 있습니다. 이러한 함수는 데이터 소스의 전체 내용을 메모리로 읽어들이므로 메모리 사용량이 최소한 데이터 소스의 크기만큼 증가합니다. readlines의 경우, Ruby VM이 각 줄을 표현하기 위해 수행해야 하는 추가 관리 작업으로 인해 더욱 증가합니다.
디스크에서 750 MB인 텍스트 파일을 읽는 다음 프로그램을 고려해 보세요.
File.readlines('large_file.txt').each do |line|
puts line
end
다음은 프로그램이 실행되는 동안의 프로세스 메모리 읽기로, 실제로 파일 전체를 메모리에 유지했음을 보여줍니다(RSS는 킬로바이트 단위로 보고됨).
$ ps -o rss -p <pid>
RSS
783436
그리고 다음은 가비지 컬렉터가 수행하던 작업의 발췌입니다.
pp GC.stat
{
:heap_live_slots=>2346848,
:malloc_increase_bytes=>30895288,
...
}
heap_live_slots(도달 가능한 객체 수)가 약 2.3M으로 급증했음을 알 수 있으며, 이는 파일을 한 줄씩 읽는 것에 비해 대략 두 자릿수 더 많습니다. 증가한 것은 원시 메모리 사용량뿐만 아니라 가비지 컬렉터(GC)가 향후 메모리 사용을 예상하여 이 변화에 대응한 방식도 마찬가지였습니다. malloc_increase_bytes가 약 30 MB로 급증한 것을 볼 수 있는데, 이는 "새로운" Ruby 프로그램의 약 4 kB와 비교됩니다. 이 수치는 Ruby GC가 다음번에 메모리가 부족해질 때 운영 체제로부터 요청하는 추가 힙 공간이 얼마나 되는지를 나타냅니다. 더 많은 메모리를 차지했을 뿐만 아니라, 애플리케이션이 더 빠른 속도로 메모리 사용을 늘리도록 동작을 변경한 것입니다.
IO.read 함수는 각 줄 객체에 대해 추가 메모리가 할당되지 않는다는 차이점을 제외하고는 유사한 동작을 보입니다.
권장 사항#
데이터 소스를 전부 메모리로 읽어들이는 대신, 한 줄씩 읽는 것이 좋습니다. YAML 파일을 Ruby Hash로 변환해야 하는 경우처럼 이것이 항상 가능한 것은 아니지만, 각 행이 처리한 후 폐기할 수 있는 어떤 엔티티를 나타내는 데이터의 경우 다음 접근 방식을 사용할 수 있습니다.
먼저 readlines.each 호출을 each 또는 each_line으로 대체합니다. each_line 및 each 함수는 이미 방문한 줄을 메모리에 유지하지 않고 데이터 소스를 한 줄씩 읽습니다.
File.new('file').each { |line| puts line }
또는 IO.readline 또는 IO.gets 함수를 사용하여 개별 줄을 명시적으로 읽을 수 있습니다.
while line = file.readline
# process line
end
이는 루프를 조기에 종료할 수 있는 조건이 있는 경우 선호될 수 있으며, 메모리뿐만 아니라 관심 없는 줄을 처리하는 데 소요되는 불필요한 CPU 및 I/O 시간도 절약할 수 있습니다.
안티패턴#
이것은 프로덕션 환경에서 측정 가능하고 유의미하며 긍정적인 영향을 미치지 않는 한 피해야 하는 안티패턴 모음입니다.
할당을 상수로 옮기기#
객체를 상수로 저장하여 한 번만 할당하면 성능이 개선될 수 있지만 보장되지는 않습니다. 상수를 조회하는 것은 런타임 성능에 영향을 미치므로, 객체를 직접 참조하는 대신 상수를 사용하면 오히려 코드가 느려질 수도 있습니다. 예를 들면 다음과 같습니다.
SOME_CONSTANT = 'foo'.freeze
9000.times do
SOME_CONSTANT
end
이렇게 해야 하는 유일한 이유는 누군가가 전역 String을 변경하는 것을 막기 위해서입니다. 그러나 Ruby에서는 상수를 다시 할당할 수 있으므로, 코드의 다른 곳에서 누군가가 다음과 같이 하는 것을 막을 수는 없습니다.
SOME_CONSTANT = 'bar'
수백만 개의 행으로 데이터베이스를 시딩하는 방법#
예를 들어 상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 수백만 개의 프로젝트 행이 필요할 수 있습니다. 이는 SQL 명령을 사용하여 직접 하거나 Mass Inserting Rails Models 기능을 사용하여 할 수 있습니다.
ActiveRecord 모델로 작업하고 있다고 가정하면, 다음 링크도 유용할 수 있습니다.
예시#
이 스니펫에서 유용한 예시를 찾을 수 있습니다.
ExclusiveLease#
Gitlab::ExclusiveLease는 개발자가 분산 서버 전반에서 상호 배제를 달성할 수 있게 해주는 Redis 기반 잠금 메커니즘입니다. 개발자가 활용할 수 있는 여러 래퍼가 있습니다.
Gitlab::ExclusiveLeaseHelpers모듈은 리스가 만료될 수 있을 때까지 프로세스나 스레드를 차단하는 헬퍼 메서드를 제공합니다.ExclusiveLeaseGuard모듈은 실행 중인 코드 블록에 대한 배타적 리스를 얻는 데 도움을 줍니다.
느린 Redis I/O가 유휴 트랜잭션 지속 시간을 증가시킬 수 있으므로 데이터베이스 트랜잭션 내에서 ExclusiveLease를 사용해서는 안 됩니다. .try_obtain 메서드는 리스 시도가 데이터베이스 트랜잭션 내에 있는지 확인하고, Sentry와 log/exceptions_json.log에 예외를 추적합니다.
테스트 또는 개발 환경에서 데이터베이스 트랜잭션 내의 리스 시도는 Gitlab::ExclusiveLease.skipping_transaction_check 블록 내에서 수행되지 않는 한 Gitlab::ExclusiveLease::LeaseWithinTransactionError를 발생시킵니다. 스킵 기능은 가능한 한 스펙에서만 사용해야 하며, 이해를 돕기 위해 리스에 최대한 가깝게 배치해야 합니다. 스펙을 DRY하게 유지하기 위해, 트랜잭션 검사 스킵이 재사용되는 코드베이스의 두 부분이 있습니다.
Users::Internal은let_it_be에서 봇 생성에 대한 트랜잭션 검사를 스킵하도록 패치되어 있습니다.:deploy_key에 대한FactoryBot팩토리는DeployKey모델 생성 동안 트랜잭션을 스킵합니다.
스펙이나 픽스처가 아닌 파일에서 Gitlab::ExclusiveLease.skipping_transaction_check를 사용하는 경우, 이를 제거할 계획에 대한 infradev 이슈 링크를 포함해야 합니다.
