- 성능 문서
- Workflow
- 도구
- 벤치마크
- Stackprof를 사용한 프로파일링
- RSpec 프로파일링
- 메모리 최적화
- 변경 사항의 중요성
- 느린 작업 및 Sidekiq
- Git 작업
- 캐싱
- 문자열 동결
- Banzai 파이프라인 및 필터
- 파일 및 기타 데이터 소스에서 읽기
- 안티 패턴
- 수백만 개의 행을 데이터베이스에 시딩하는 방법
- ExclusiveLease
성능 지침
이 문서는 GitLab의 좋고 일관된 성능을 보장하기 위해 따를 수 있는 다양한 지침을 설명합니다. 성능 관련 페이지로 이동하려면 아래의 색인 섹션을 참조하세요.
성능 문서
- 일반:
- 백엔드:
- 프론트엔드:
- QA:
- 모니터링 및 개요:
- Self-managed 관리 및 고객 중심:
Workflow
성능 문제 해결 프로세스는 대략 다음과 같습니다.
- 문제가 어딘가 열려 있는지 확인하고(예: GitLab CE 이슈 트래커에), 그렇지 않으면 생성하세요. 예: #15607를 참조하세요.
- 성능을 GitLab.com과 같은 프로덕션 환경에서 측정하세요(아래의 도구 섹션 참조). 성능은 최소 24시간 동안 측정되어야 합니다.
- 측정 기간 동안의 결과(그래프 스샷, 타이밍 등)을 1단계에서 언급된 이슈에 추가하세요.
- 문제를 해결하세요.
- 병합 요청을 생성하고 “성능” 레이블을 할당한 다음 성능 검토 프로세스를 따르세요.
- 변경이 배포되면 반드시 다시 최소 24시간 동안 측정하여 생산 환경에 어떤 영향을 미치는지 확인하세요.
- 완료될 때까지 반복하세요.
타이밍을 제공할 때 반드시 제공해야 하는 사항:
- 95백분위수
- 99백분위수
- 평균
그래프 스샷을 제공할 때는 X 및 Y 축과 범례가 명확히 보이도록 하세요. GitLab.com의 모니터링 도구에 액세스할 수 있다면 관련된 그래프/대시보드에 대한 링크도 제공해야 합니다.
도구
GitLab은 성능과 가용성을 향상시키는 데 도움이 되는 기본 도구를 제공합니다.
- 프로파일링.
- 분산 추적
- GitLab 성능 모니터링.
-
N+1
회귀를 방지하기 위한 QueryRecoder. - 가용성을 테스트하기 위한 Chaos endpoints. 주로 가용성을 테스트하는 데 사용됩니다.
- 서비스 실행을 측정하고 로깅하기 위한 서비스 측정.
GitLab 팀 멤버는 자신의 @gitlab.com
이메일 주소를 사용하여 dashboards.gitlab.net
의 GitLab.com 성능 모니터링 시스템을 사용할 수 있습니다. GitLab 팀 멤버가 아닌 경우, 자체 Prometheus 및 Grafana 스택을 설정하는 것이 좋습니다.
벤치마크
벤치마크는 거의 항상 쓸모없습니다. 대부분 벤치마크는 일반적으로 고립된 코드의 작은 부분만 테스트하고 종종 최상의 시나리오만을 측정합니다. 게다가, 라이브러리에 대한 벤치마크는 종종 해당 라이브러리를 선호하도록 편향됩니다. 결국 어떤 작성자가 자신의 경쟁 상대보다 성능이 떨어지는 것을 보여주는 벤치마크를 게시하는 것에는 거의 이익이 없기 때문입니다.
벤치마크는 대개 귀하의 변경 사항의 영향을 대략적으로 이해해야 할 때에만 유용합니다. 예를 들어, 특정 메소드가 느리면 벤치마크를 사용하여 귀하의 변경 사항이 메소드의 성능에 어떤 영향을 미치는지 확인할 수 있습니다. 그런데, 벤치마크가 귀하의 변경 사항이 성능을 개선한다고 보여준다 하더라도 이러한 변경이 프로덕션 환경에서 성능도 개선될 것이라는 보장은 없습니다.
벤치마크를 작성할 때 거의 항상 표준 라이브러리와 함께 제공되는 Ruby의 Benchmark
모듈 대신 benchmark-ips를 사용해야 합니다. 또 다른 문제는 Benchmark
모듈이 시간을 표시하는 반면 반복 횟수를 표시하지 않는다는 것입니다. 이로 인해 일부 코드가 매우 짧은 시간에 완료되면 특정 변경 전후의 타이밍을 비교하는 것이 매우 어려울 수 있습니다. 이는 다음과 같은 패턴을 유발할 수 있습니다.
Benchmark.bmbm(10) do |bench|
bench.report '작업' do
100번 반복
... 작업 ...
end
end
end
이러한 문제들은 전반적인 통계치를 얻기 위해 몇 번의 반복을 실행해야 하는지라는 질문을 던집니다.
benchmark-ips
젬은 이에 대해 많은 것을 다루고 있습니다. 따라서 Benchmark
모듈 대신 이를 사용해야 합니다.
GitLab Gemfile에는 benchmark-memory
젬도 포함되어 있으며, 이는 benchmark
및 benchmark-ips
젬과 유사하게 작동합니다. 그러나 benchmark-memory
는 벤치마크 후 메모리 크기, 할당된 객체 및 문자열을 반환합니다.
요약하면:
- 인터넷에서 찾은 벤치마크를 신뢰해서는 안 됩니다.
- 벤치마크에 기반하여 주장해서는 안 됩니다. 항상 생산 환경에서 측정하여 결과를 확인하세요.
- X가 Y보다 N배 빠르다는 주장은 생산 환경에 미치는 영향을 모른다면 의미가 없습니다.
- 프로덕션 환경은 항상 진실을 말하는 유일한 벤치마크입니다(성능 모니터링 시스템이 올바르게 설정되어 있지 않다면).
- 반드시 벤치마크를 작성해야 한다면
Benchmark
모듈 대신benchmark-ips
젬을 사용해야 합니다.
Stackprof를 사용한 프로파일링
정기적인 간격으로 프로세스 상태 스냅샷을 수집함으로써 프로파일링은 프로세스 내에서 시간이 어디에 소비되는지 확인할 수 있습니다. Stackprof 젬은 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
도우미를 사용하여 해당 사양을 실행하면 됩니다.
$ LIMIT=10 bin/rspec-stackprof 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
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
모드의 경우 이는 이벤트 당 간격으로 (nth
이벤트마다 샘플링) 설정됩니다. 기본값은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
명령행 도구를 통해 추가로 검사할 수 있습니다.
현재 지원되는 프로파일링 대상은 다음과 같습니다:
- Puma 워커
- Sidekiq
참고:
Puma 마스터 프로세스는 지원되지 않습니다. Puma에 경우 SIGUSR2
를 보내면 다시 시작이 트리거됩니다.
Puma의 경우에는 pkill -USR2 puma:
를 통해 신호를 보내기만 하면 됩니다. :
는 puma 4.3.3.gitlab.2 ...
(마스터 프로세스)와 puma: 클러스터 워커 0: ...
(워커 프로세스)를 구분하기 위한 것입니다.
Sidekiq의 경우, pkill -USR2 bin/sidekiq-cluster
를 통해 신호를 sidekiq-cluster
프로세스에 보내어 Sidekiq 자식에게 신호를 전달할 수 있습니다. 또는 특정 PID를 선택할 수도 있습니다.
스택프로파일(PROFILE) 읽기
기본적으로 출력은 ‘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
를 실행할 때 RAW
환경 변수를 true
로 설정해야합니다.
출력 파일 크기에 따라 생성하는데 시간이 걸릴 수 있습니다:
# 생성
stackprof --flamegraph tmp/group_member_policy_spec.rb.dump > group_member_policy_spec.flame
# 보기
stackprof --flamegraph-viewer=group_member_policy_spec.flame
플레임 그래프를 SVG 파일로 내보내려면 Brendan Gregg’s 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
스택프로프를 사용하여 스펙을 프로파일할 때, 프로필에는 테스트 스위트 및 응용 프로그램 코드에서 수행된 작업이 포함됩니다. 따라서 이러한 프로필을 사용하여 느린 테스트를 조사할 수도 있습니다. 그러나 이 예제와 같이 작은 실행에는 테스트 스위트를 설정하는 데 드는 비용이 주로 지배적인 경향이 있습니다.
RSpec 프로파일링
깃랩 개발 환경에는 rspec_profiling
젬이 포함되어 있어 스펙 실행 시간에 대한 데이터를 수집하는 데 사용됩니다. 이는 테스트 스위트 자체의 성능을 분석하거나 특정 스펙의 성능이 시간이 지남에 따라 어떻게 변경되었는지 확인하는 데 유용합니다.
로컬 환경에서 프로파일링을 활성화하려면 다음을 실행하세요:
export RSPEC_PROFILING=yes
rake rspec_profiling:install
이렇게 하면 RSPEC_PROFILING
환경 변수가 설정된 상태에서 스펙을 실행할 때마다 tmp/rspec_profiling
에 SQLite3 데이터베이스가 생성되며 여기에 통계가 저장됩니다.
수집된 결과를 대화형 쉘에서 분석할 수도 있습니다:
$ rake rspec_profiling:console
irb(main):001:0> results.count
=> 231
irb(main):002:0> results.last.attributes.keys
=> ["id", "commit", "date", "file", "line_number", "description", "time", "status", "exception", "query_count", "query_time", "request_count", "request_time", "created_at", "updated_at"]
irb(main):003:0> results.where(status: "passed").average(:time).to_s
=> "0.211340155844156"
이러한 결과는 RSPEC_PROFILING_POSTGRES_URL
변수를 설정하여 PostgreSQL 데이터베이스에도 저장할 수 있습니다. 이는 CI 환경에서 실행할 때 테스트 스위트를 프로파일링하는 데 사용됩니다.
이러한 결과는 또한 gitlab.com
의 기본 브랜치에서 매일 스케줄된 CI 작업을 실행할 때에도 저장됩니다. 이러한 프로파일링 데이터의 통계는 온라인에서 사용 가능합니다. 예를 들어, 어떤 테스트가 가장 오래 걸리는지 또는 가장 많은 쿼리를 실행하는지 찾을 수 있습니다. 이를 사용하여 테스트를 최적화하거나 코드의 성능 문제를 식별하세요.
메모리 최적화
메모리 문제를 찾기 위해 종종 복합적으로 다양한 기술을 사용할 수 있습니다.
- 코드를 그대로 두고 프로파일러를 사용합니다.
- 요청 및 서비스에 대한 메모리 할당 카운터를 사용합니다.
- 코드의 일부를 비활성화/활성화하면서 프로세스의 메모리 사용량을 모니터링합니다.
메모리 할당
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
가 생성됩니다. 각각은 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
를 생성합니다. 각각은 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}
-
다음 예제는 40KB 이상의 데이터를 할당하며, 단일 메모리 할당만 수행합니다. 기존 객체는 나중에 다시 할당/크기 조정됩니다.
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
젬은 GitLab Gemfile
에 이미 존재합니다. 또한 성능 바
에서도 현재 URL에 사용할 수 있습니다.
메모리 프로파일러를 직접 코드에서 사용하려면 require
를 사용하여 추가하십시오:
require 'memory_profiler'
report = MemoryProfiler.report do
# 프로파일링하려는 코드
end
output = File.open('/tmp/profile.txt','w')
report.pretty_print(output)
보고서는 젬, 파일, 위치 및 클래스별로 그룹화된 보유 및 할당된 메모리를 보여줍니다. 메모리 프로파일러는 또한 문자열이 얼마나 자주 할당되고 유지되는지 보여주는 문자열 분석을 수행합니다.
유지된 메모리 vs 할당된 메모리
- 유지된 메모리: 코드 블록 실행으로 유지된 장기 메모리 사용 및 객체 수입니다. 이것은 메모리와 가비지 수집기에 직접적으로 영향을 미칩니다.
- 할당된 메모리: 코드 블록 동안의 모든 객체 할당 및 메모리 할당입니다. 이것은 메모리에는 미미한 영향을 미칠 수 있지만 성능에는 상당한 영향을 미칩니다. 할당한 객체가 많을수록 작업량이 많아지고 응용프로그램이 느려집니다.
일반적인 규칙으로는 유지된이 항상 할당된보다 작거나 같습니다.
MRI 힙은 크기가 조정되지 않으며 메모리 조각이기 때문에 실제 RSS 비용은 항상 약간 더 높습니다.
Rbtrace
증가된 메모리 풋프린트의 이유 중 하나는 Ruby 메모리 단편화일 수 있습니다.
이를 진단하려면 Aaron Patterson가 설명한 이 게시글에서 설명한대로 Ruby 힙을 시각화할 수 있습니다.
먼저 조사 중인 프로세스의 힙을 JSON 파일로 덤프해야 합니다.
rbtrace
를 사용하여 조사 중인 프로세스 내에서 명령을 실행해야 합니다.
rbtrace
는 이미 GitLab Gemfile
에 존재하며, 그냥 요구하면 됩니다.
웹 서버나 Sidekiq를 실행하고 환경 변수를 ENABLE_RBTRACE=1
로 설정할 수 있습니다.
힙 덤프하는 방법은 다음과 같습니다:
bundle exec rbtrace -p <PID> -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 앱을 벤치마크하는 데 사용할 수 있는 일련의 도구”로 설명되는 젬입니다.
우리는 derailed_benchmarks
를 우리의 Gemfile
에 포함시킵니다.
모든 파이프라인에서 test
단계에 memory-on-boot
이라는 작업을 실행하여 derailed exec perf:mem
을 실행합니다. (예제 작업을 참조하십시오.)
결과는 다음에서 찾을 수 있습니다:
- 병합 요청 개요 탭의 병합 요청 보고서 영역
-
Metrics Reports 드롭다운 목록에
memory-on-boot
artifacts에서 전체 보고서 및 종속성 분석입니다.
derailed_benchmarks
는 메모리를 조사하기 위한 다른 방법도 제공합니다. 자세한 내용은 젬 문서를 참조하십시오.
대부분의 방법(derailed exec perf:*
)은 Rails 앱을 production
환경에서 부팅하고 벤치마크를 실행하려고 시도합니다.
GDK 및 GCK 모두 가능합니다:
- GDK의 경우 젬 페이지에 있는 지침을 따르십시오. 오류를 피하려면 Redis 구성도 유사하게 수행해야 합니다.
- GCK는
production
구성 섹션을 기본으로 포함하고 있습니다.
변경 사항의 중요성
성능 개선 작업 시에는 언제나 “이 코드의 성능을 개선하는 것이 얼마나 중요한가?”라는 질문을 하는 것이 중요합니다. 모든 코드 조각이 동등하게 중요한 것은 아니며, 작은 부분에 영향을 미치는 것을 개선하기 위해 일주일을 소비하는 것은 낭비일 수 있습니다. 예를 들어, 메소드에서 10밀리초를 빼내기 위해 일주일을 보내는 것은 10초를 다른 곳에서 뽑는 데 일주일을 보낼 수 있었던 것 처럼 시간 낭비입니다.
특정 코드 조각을 최적화할 가치가 있는지 판단하는 데 따르는 명확한 단계 세트가 없습니다. 할 수 있는 것은 두 가지뿐입니다:
- 코드가 하는 일, 사용 방법, 호출 횟수 및 예를 들어 웹 요청의 총 실행 시간에 비해 얼마나 많은 시간이 소요되는지 등에 대해 고려해 보십시오.
- 다른 사람들에게 물어보십시오 (가능하면 이슈 형태로).
중요하지 않거나 노력할 가치가 없는 변경 사항의 몇 가지 예시:
- 이중 따옴표를 단일 따옴표로 대체하는 것.
- 값 목록이 매우 작을 때 Array 사용을 Set으로 대체하는 것.
- 라이브러리 A를 라이브러리 B로 대체하는 것, 둘 다 총 실행 시간의 0.1%만 차지할 때.
- 모든 문자열에
freeze
를 호출하는 것(참조: String Freezing).
느린 작업 및 Sidekiq
소스 브랜치를 병합하는 것과 같은 느린 작업 또는 (외부 API를 사용하는) 오류가 발생할 가능성이 있는 작업은 가능한 한 직접 웹 요청이 아닌 Sidekiq 워커에서 수행해야 합니다. 이에는 다음과 같은 많은 이점이 있습니다:
- 오류로 인해 요청이 완료되지 못하는 일을 방지합니다.
- 프로세스가 느리더라도 페이지의 로드 시간에 영향을 주지 않습니다.
- 실패한 경우 프로세스를 다시 시도할 수 있습니다(Sidekiq가 이를 자동으로 처리함).
- 웹 요청으로부터 코드를 격리함으로써 테스트 및 유지관리를 더 쉽게 할 수 있습니다.
특히 기존 저장 시스템의 성능에 따라 Git 작업에 상당한 시간이 소요될 수 있으므로 Git 작업과 관련된 경우 가능한 한 Sidekiq를 사용하는 것이 매우 중요합니다.
Git 작업
불필요한 Git 작업을 실행하지 않도록 주의해야 합니다. 예를 들어 Repository#branch_names
를 사용하여 브랜치 이름 목록을 검색할 수 있는데 이때 명시적으로 저장소가 있는지 여부를 확인할 필요가 없습니다. 즉, 이렇게 쓰는 대신:
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
문자열 동결
최근 Ruby 버전에서 문자열에 .freeze
를 호출하면 한 번만 할당되고 재사용됩니다. 예를 들어, Ruby 2.3 이상에서는 다음과 같이 “foo” 문자열을 한 번만 할당합니다:
10.times do
'foo'.freeze
end
문자열의 크기 및 .freeze
호출 전에 얼마나 자주 할당되는지에 따라 이러한 접근 방식이 더 빨라질 수 있지만 이것은 보장되는 것은 아닙니다.
문자열을 동결하는 것은 적어도 하나의 RVALUE_SIZE
바이트(64비트 시스템에서 40바이트)의 메모리를 사용하는 모든 할당된 문자열의 메모리를 저장합니다.
또한, 이벤트에 대비하기 위해 Ruby 3.0에서는 기본적으로 문자열이 동결됩니다. 이를 대비하여 모든 Ruby 파일에 다음 헤더를 추가하고 있습니다:
# frozen_string_literal: true
이로 인해 문자열을 조작할 수 있는 코드에서 테스트 실패가 발생할 수 있습니다. 더 이상 dup
대신에 얼어붙지 않은 문자열을 얻기 위해 단항 플러스를 사용하세요:
test = +"hello"
test += " world"
새로운 Ruby 파일을 추가할 때 위의 헤더를 추가할 수 있는지 확인하고 이를 빠트리면 스타일 체크 실패로 이어질 수 있으니 주의하세요.
Banzai 파이프라인 및 필터
Banzai 필터 및 파이프라인를 작성하거나 업데이트할 때, 필터의 성능과 전체 파이프라인 성능에 어떤 영향을 미칠지 이해하기 어려울 수 있습니다.
벤치마크를 수행하려면 다음을 실행하세요:
bin/rake benchmark:banzai
이 명령은 다음과 같은 출력을 생성합니다:
--> 전체, 위키 및 일반 파이프라인 벤치마킹
계산 중 -------------------------------------
전체 파이프라인 1.000 i/100ms
위키 파이프라인 1.000 i/100ms
일반 파이프라인 1.000 i/100ms
-------------------------------------------------
전체 파이프라인 3.357 (±29.8%) i/s - 31.000
위키 파이프라인 2.893 (±34.6%) i/s - 25.000 in 10.677014s
일반 파이프라인 15.447 (±32.4%) i/s - 119.000
비교:
일반 파이프라인: 15.4 i/s
전체 파이프라인: 3.4 i/s - 4.60배 느림
위키 파이프라인: 2.9 i/s - 5.34배 느림
...
--> Plain 파이프라인 필터 벤치마킹
계산 중 -------------------------------------
마크다운 24.000 i/100ms
Plantuml 8.000 i/100ms
SpacedLink 22.000 i/100ms
...
작업 목록 49.000 i/100ms
인라인 차이 9.000 i/100ms
방향 설정 369.000 i/100ms
-------------------------------------------------
마크다운 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
...
작업 목록 101.145 (± 6.9%) i/s - 1.029k
인라인 차이 52.925 (±15.1%) i/s - 522.000
방향 설정 3.728k (±17.2%) i/s - 34.317k in 10.617882s
비교:
제안: 739616.9 i/s
Kroki: 306449.0 i/s - 2.41배 느림
InlineGrafanaMetrics: 156535.6 i/s - 4.72배 느림
방향 설정: 3728.3 i/s - 198.38배 느림
...
사용자 참조: 2.1 i/s - 360365.80배 느림
외부 링크: 1.6 i/s - 470400.67배 느림
프로젝트 참조: 0.7 i/s - 1128756.09배 느림
...
--> 일반 마크다운 파이프라인 필터 벤치마킹
계산 중 -------------------------------------
마크다운 19.000 i/100ms
-------------------------------------------------
마크다운 241.476 (±15.3%) i/s - 2.356k
이를 통해 다양한 필터의 성능 및 가장 느린 필터를 파악할 수 있습니다.
테스트 데이터는 필터의 성능에 많은 영향을 미칩니다. 특정 필터를 구체적으로 트리거하는 테스트 데이터가 없으면 굉장히 빠르게 실행되는 것처럼 보일 수 있습니다. 필터에 대한 관련 테스트 데이터가 spec/fixtures/markdown.md.erb
파일에 있는지 확인하세요.
특정 필터의 벤치마킹
특정 필터는 환경 변수로 필터 이름을 지정하여 벤치마킹할 수 있습니다.
예를 들어, MarkdownFilter
를 벤치마킹하려면 다음을 사용하세요.
FILTER=MarkdownFilter bin/rake benchmark:banzai
그러면 다음과 같은 결과가 생성됩니다.
--> FullPipeline의 MarkdownFilter 벤치마킹
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이 수행해야 하는 추가 작업으로 인해 더욱 많이 증가합니다.
다음은 디스크에 있는 750MB 크기의 텍스트 파일을 읽는 아래 프로그램입니다.
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
가 약 30MB로 증가했음을 볼 수 있는데, 이는 “새로운” Ruby 프로그램의 경우에는 단지 약 4kB에 불과합니다. 이 수치는 Ruby GC가 메모리 부족 상태에서 다음에 운영 체제로부터 청구할 추가 힙 공간의 크기를 지정합니다. 우리는 메모리를 더 많이 사용했을 뿐만 아니라 애플리케이션의 동작도 변경하여 메모리 사용률이 더 빠르게 증가하도록 했습니다.
IO.read
함수도 각 줄 객체에 대해 추가 메모리가 할당되지 않는 유사한 동작을 보입니다.
권장사항
데이터 소스를 전체적으로 메모리에 읽는 대신 한 줄씩 읽는 것이 더 나은 방법입니다. 이것이 항상 선택사항은 아니며, 예를 들어 각 행이 처리된 후에 폐기될 수 있는 데이터가 있는 경우에는 다음 접근 방법을 사용할 수 있습니다.
먼저, readlines.each
호출을 each
또는 each_line
으로 대체하세요.
each_line
및 each
함수는 이미 방문한 라인을 메모리에 보관하지 않고 데이터 소스를 한 줄씩 읽습니다.
File.new('file').each { |line| puts line }
또는 명시적으로 개별 라인을 IO.readline
또는 IO.gets
함수를 사용하여 읽을 수 있습니다.
while line = file.readline
# 라인 처리
end
이것은 관심 없는 라인의 처리에 소요된 불필요한 시간과 CPU 및 I/O에 소요된 시간을 아껴주는 조건이 있는 경우에는 더 좋을 수 있습니다.
안티 패턴
이것은 안티 패턴의 모음입니다. 이러한 변경 사항이 프로덕션 환경에 측정 가능하고 중요한 영향을 미치는 경우를 제외하고는 피해야할 패턴들입니다.
할당을 상수로 이동
객체를 한 번만 할당하도록 상수로 저장하는 것은 성능을 향상시킬 수 있지만 보장되는 것은 아닙니다. 상수를 찾아보는 것은 런타임 성능에 영향을 미치며, 따라서 객체를 직접 참조하는 대신 상수를 사용하면 코드가 더 느려질 수도 있습니다. 예를 들어:
SOME_CONSTANT = 'foo'.freeze
9000.times do
SOME_CONSTANT
end
이것을 하는 유일한 이유는 전역 String의 변형을 방지하기 위한 것입니다. 그러나 Ruby에서 상수를 다시 할당할 수 있기 때문에 다음과 같이 다른 곳에서도 이것을 할 수 있습니다.
SOME_CONSTANT = 'bar'
수백만 개의 행을 데이터베이스에 시딩하는 방법
예를 들어 상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 수백만 개의 프로젝트 행이 필요할 수 있습니다. 이를 SQL 명령이나 Rails 모델의 대량 삽입 기능을 사용하여 수동으로 수행할 수 있습니다.
ActiveRecord 모델로 작업하는 경우, 다음 링크가 도움이 될 수 있습니다:
예시
이 스니펫에서 유용한 예시를 찾을 수 있습니다.
ExclusiveLease
Gitlab::ExclusiveLease
는 분산된 서버 전체에서 상호 배타적인 접근을 가능하게 하는 Redis 기반의 잠금 메커니즘입니다. 개발자들이 사용할 수 있는 여러 래퍼(wrapper)가 있습니다:
-
Gitlab::ExclusiveLeaseHelpers
모듈은 프로세스나 스레드가 임대 기한이 만료될 때까지 블록되는 도우미 메서드를 제공합니다. -
ExclusiveLease::Guard
모듈은 코드 블록에 대한 배타적 임대를 얻는 데 도움을 줍니다.
어떤 느린 Redis I/O도 비어 있는 트랜잭션 기간을 늘릴 수 있기 때문에 데이터베이스 트랜잭션에서 ExclusiveLease
를 사용해서는 안 됩니다. .try_obtain
메서드는 임대 시도가 어떤 데이터베이스 트랜잭션 내에서 수행되는지 확인하고 Sentry에서 예외를 추적하며 log/exceptions_json.log
에 예외를 기록합니다.
테스트나 개발 환경에서 데이터베이스 트랜잭션 내에서 임대 시도는 Gitlab::ExclusiveLease::LeaseWithinTransactionError
를 일으킵니다. 이 오류는 Gitlab::ExclusiveLease.skipping_transaction_check
블록 내에서 수행되지 않을 경우입니다. 우리가 가능한 경우, 스펙(spec)에서만 스킵 기능을 사용하고 이해하기 쉽도록 가능한 가까운 위치에 배치해야 합니다. 스펙을 DRY(Don’t Repeat Yourself)하게 유지하기 위해 트랜잭션 확인 스킵은 코드베이스 내의 두 부분에서 재사용됩니다:
-
Users::Internal
은let_it_be
에서 봇 생성을 위한 트랜잭션 확인을 스킵하기 위해 패치되었습니다. -
:deploy_key
의FactoryBot
팩토리는DeployKey
모델 생성 중에 트랜잭션을 스킵합니다.
비스펙 또는 비픽스처 파일에서의 Gitlab::ExclusiveLease.skipping_transaction_check
사용은 계획된 제거 작업에 대한 인프라 개발(infradev) 이슈 링크를 포함해야 합니다.