- 성능 문서
- 워크플로우
- 도구
- 벤치마크
- Stackprof을 통한 프로파일링
- RSpec 프로파일링
- 메모리 최적화
- 변경의 중요성
- 느린 작업 및 Sidekiq
- Git 작업
- 캐싱
- 문자열 고정
- Banzai 파이프라인 및 필터
- 파일 및 기타 데이터 소스에서 읽기
- 안티 패턴
- 수백만 개의 행으로 데이터베이스를 시드하는 방법
- ExclusiveLease
성능 가이드라인
이 문서는 GitLab의 좋은 일관된 성능을 보장하기 위한 다양한 가이드라인을 설명합니다.
성능 문서
- 일반:
- 백엔드:
- 프론트엔드:
- QA:
- 모니터링 및 개요:
- 자체 관리형 관리 및 고객 중심:
워크플로우
성능 문제를 해결하는 과정은 대략 다음과 같습니다:
-
어딘가에 문제가 열려 있는지 확인합니다(예: GitLab CE 이슈 트래커에서) 없다면 하나 만듭니다. 예시로 #15607를 참조하세요.
-
GitLab.com과 같은 프로덕션 환경에서 코드의 성능을 측정합니다 (아래 툴링 섹션을 참조). 최소 24시간 동안 성능을 측정해야 합니다.
-
측정 기간을 기준으로 찾은 내용을 스크린샷(그래프, 타이밍 등)과 함께 1단계에 언급된 이슈에 추가합니다.
-
문제를 해결합니다.
-
병합 요청을 생성하고 “성능” 라벨을 할당한 후 성능 검토 프로세스를 따릅니다.
-
변경 사항이 배포된 후 최소 24시간 동안 다시 측정하여 변경 사항이 프로덕션 환경에 영향을 미쳤는지 확인합니다.
-
완료될 때까지 반복합니다.
타이밍을 제공할 때는 다음을 포함해야 합니다:
- 95번째 백분위수
- 99번째 백분위수
- 평균
그래프의 스크린샷을 제공할 때는 X축과 Y축, 범례가 명확하게 보이도록 하세요. GitLab.com의 모니터링 도구에 접근할 수 있는 경우 관련 그래프/대시보드에 대한 링크도 제공해야 합니다.
도구
GitLab은 성능 및 가용성을 개선하는 데 도움이 되는 빌트인 도구를 제공합니다:
- 프로파일링.
- 분산 추적
- GitLab 성능 모니터링.
-
N+1
회귀를 방지하기 위한 QueryRecoder. - 주로 가용성 테스트를 위한 Chaos endpoints.
- 서비스 실행을 측정하고 로깅하기 위한 서비스 측정.
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
... 작업 여기 ...
end
end
end
그러나 이 경우에는 의미 있는 통계를 얻기 위해 얼마나 많은 반복을 수행해야 하는지에 대한 질문이 생깁니다.
benchmark-ips
gem은 이 모든 것과 그 이상을 처리합니다. 따라서 Benchmark
모듈 대신 이것을 사용해야 합니다.
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
이렇게 하면 Stackprof 프로파일 읽기를 위한 .dump
파일이 생성됩니다.
사용 가능한 모든 옵션은 Stackprof 문서를 참조하세요.
성능 바
성능 바를 사용하면 Stackprof를 사용하여 요청을 프로파일링하고 그 결과를 Speedscope 플레임 그래프로 즉시 출력할 수 있는 옵션이 있습니다.
Stackprof를 이용한 RSpec 프로파일링
특정 스펙에서 프로파일을 생성하려면 문제의 코드 경로를 실행하는 스펙을 식별(또는 생성)한 후, bin/rspec-stackprof
헬퍼를 사용하여 실행합니다. 예를 들어:
$ LIMIT=10 bin/rspec-stackprof spec/policies/project_policy_spec.rb
8/8 |====== 100 ======>| 시간: 00:00:18
18.19초 동안 완료됨(파일 로드에 4.8초 소요됨)
8개의 예제, 0개의 실패
==================================
모드: wall(1000)
샘플: 17033 (5.59% 누락율)
GC: 1901 (11.16%)
==================================
총합 (백분율) 샘플 (백분율) 프레임
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
모드의 경우 이벤트당 간격(매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
명령줄 도구를 통해 더 자세히 검사할 수 있습니다.
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
로 설정해야 합니다.
출력 파일 크기에 따라 생성하는 데 시간이 좀 걸릴 수 있습니다:
# 생성하기
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의 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)
샘플: 0 자기 (0.0%) / 578 총 (0.7%)
호출자:
397 ( 68.7%) 블록 (2 레벨) 내
호출된 메소드 (총 578): 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
코드: | 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
젬이 포함되어 있습니다.
이는 테스트 스위트 자체의 성능을 분석하거나 스펙의 성능이 시간이 지남에 따라 어떻게 변화했는지 확인하는 데 유용합니다.
로컬 환경에서 프로파일링을 활성화하려면 다음을 실행하세요:
export RSPEC_PROFILING=yes
rake rspec_profiling:install
이것은 tmp/rspec_profiling
에 SQLite3 데이터베이스를 생성하며,
여기에 RSPEC_PROFILING
환경 변수를 설정하고 스펙을 실행할 때마다 통계가 저장됩니다.
수집된 결과를 대화형 셸에서 즉석에서 조사할 수 있습니다:
$ 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 작업을 실행할 때도 이러한 결과를 저장합니다.
이 프로파일링 데이터의 통계는 온라인에서 이용할 수 있습니다.
예를 들어, 가장 오랫동안 실행되는 테스트나 가장 많은 쿼리를 실행하는 테스트를 찾을 수 있습니다.
이를 이용하여 우리의 테스트를 최적화하거나 코드의 성능 문제를 식별하세요.
메모리 최적화
우리는 메모리 문제를 추적하기 위해 여러 가지 기술을 조합하여 사용할 수 있습니다:
- 코드를 intact으로 유지하고 그 주위에 프로파일러를 감쌉니다.
- 요청 및 서비스에 대한 메모리 할당 카운터를 사용합니다.
- 문제가 있을 수 있다고 의심되는 코드의 다양한 부분을 비활성화/활성화하면서 프로세스의 메모리 사용량을 모니터링합니다.
메모리 할당
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에서 객체와 메모리가 할당되는 방식의 다양한 측면을 나타냅니다:
-
다음 예제는 문자열이 동결될 수 있으므로
mem_objects
의 약1000
을 생성합니다.기본 문자열 객체는 동일하게 유지되지만 이 문자열에 대한 1000개의 참조를 할당해야 합니다:
Gitlab::Memory::Instrumentation.with_memory_allocations do 1_000.times { '0123456789' } end => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0}
-
다음 예제는 문자열이 동적으로 생성되므로
mem_objects
의 약1000
을 생성합니다.각 문자열은 Ruby 슬롯인 40 바이트에 맞기 때문에 추가 메모리를 할당하지 않습니다:
Gitlab::Memory::Instrumentation.with_memory_allocations do s = '0' 1_000.times { s * 23 } end => {:mem_objects=>1002, :mem_bytes=>0, :mem_mallocs=>0}
-
다음 예제는 문자열이 동적으로 생성되므로
mem_objects
의 약1000
을 생성합니다.각 문자열은 Ruby 슬롯인 40 바이트보다 크기 때문에 추가 메모리를 할당합니다:
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}
-
다음 예제는 1k 이상의 객체를 생성하고, 각 시간마다 객체를 변형하면서 1k 이상의 할당을 수행합니다.
이는 많은 데이터를 복사하고 많은 메모리 할당을 수행하게 됩니다
(이는
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
gem은 이미 GitLab Gemfile
에 존재합니다. 현재 URL의 성능 바에서도 사용할 수 있습니다.
코드에서 메모리 프로파일러를 직접 사용하려면 require
로 추가하세요:
require 'memory_profiler'
report = MemoryProfiler.report do
# 프로파일링할 코드
end
output = File.open('/tmp/profile.txt','w')
report.pretty_print(output)
보고서는 gem, 파일, 위치 및 클래스별로 그룹화된 유지 및 할당된 메모리를 보여줍니다. 메모리 프로파일러는 문자열 분석도 수행하여 문자열이 얼마나 자주 할당되고 유지되는지를 보여줍니다.
유지된 메모리 대 할당된 메모리
- 유지된 메모리: 코드 블록 실행으로 인한 장기적인 메모리 사용 및 객체 수입니다. 이는 메모리와 가비지 수집기에 직접적인 영향을 미칩니다.
- 할당된 메모리: 코드 블록 동안의 모든 객체 할당 및 메모리 할당입니다. 이는 메모리에 미치는 영향은 최소일 수 있지만, 성능에는 상당한 영향을 미칠 수 있습니다. 객체를 많이 할당할수록 더 많은 작업이 이루어지며 애플리케이션이 느려집니다.
일반적으로 유지된 메모리는 항상 할당된 메모리보다 작거나 같습니다.
실제 RSS 비용은 MRI 힙이 사이즈로 압축되지 않고 메모리 조각화가 발생하기 때문에 항상 약간 더 높습니다.
Rbtrace
메모리 사용량 증가의 한 가지 이유는 Ruby 메모리 조각화일 수 있습니다.
이를 진단하기 위해, Aaron Patterson의 이 게시물에 설명된 대로 Ruby 힙을 시각화할 수 있습니다.
시작하려면, 조사 중인 프로세스의 힙을 JSON 파일로 덤프해야 합니다.
탐색하는 프로세스 내에서 명령을 실행해야 하며, rbtrace
를 사용하여 이를 수행할 수 있습니다. rbtrace
는 이미 GitLab Gemfile
에 존재하며, 단지 이를 required 하면 됩니다. ENABLE_RBTRACE=1
환경 변수를 설정하여 웹 서버 또는 Sidekiq를 실행하면 됩니다.
힙 덤프를 얻으려면:
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 앱을 벤치마킹하는 데 사용할 수 있는 일련의 것들”로 설명되는 gem입니다.
우리는 Gemfile
에 derailed_benchmarks
를 포함합니다.
우리는 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으로 교체하기.
- 라이브러리 A를 라이브러리 B로 교체할 때 두 라이브러리 모두 전체 실행 시간의 0.1%만 차지하는 경우.
- 모든 문자열에서
freeze
호출하기(자세한 내용은 문자열 동결 참조).
느린 작업 및 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
캐싱
자주 동일한 결과를 반환하는 작업은 Redis를 사용하여 캐시해야 하며, 특히 Git 작업에 해당합니다. Redis에 데이터를 캐시할 때는 필요할 때마다 캐시를 플러시해야 합니다. 예를 들어, 태그 목록 캐시는 새로운 태그가 푸시되거나 태그가 제거될 때마다 플러시해야 합니다.
리포지토리의 캐시 만료 코드를 추가할 때, 이 코드는 Repository 클래스에 있는 사전/후 훅 중 하나에 배치해야 합니다. 예를 들어, 리포지토리를 가져온 후 캐시를 플러시해야 한다면 이 코드를 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
바이트 (x64에서 40바이트)의 메모리를 사용합니다.
어떤 문자열이 자주 할당되고 .freeze
의 혜택을 받을 수 있는지 보려면 메모리 프로파일러를 사용할 수 있습니다.
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배 느림
.
--> 전체 파이프라인 필터 벤치마킹
계산 중 -------------------------------------
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
비교:
제안: 739616.9 i/s
Kroki: 306449.0 i/s - 2.41배 느림
InlineGrafanaMetrics: 156535.6 i/s - 4.72배 느림
SetDirection: 3728.3 i/s - 198.38배 느림
...
UserReference: 2.1 i/s - 360365.80배 느림
ExternalLink: 1.6 i/s - 470400.67배 느림
ProjectReference: 0.7 i/s - 1128756.09배 느림
.
--> 일반 Markdown 파이프라인 필터 벤치마킹
계산 중 -------------------------------------
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
다음과 같은 출력이 생성됩니다.
--> MarkdownFilter에 대한 FullPipeline 벤치마킹
워밍업 --------------------------------------
Markdown 271.000 i/100ms
계산중 -------------------------------------
Markdown 2.584k (±16.5%) i/s - 23.848k 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
가 약 30MB로 증가했으며, 이는 “신선한” Ruby 프로그램의 약 4kB와 비교됩니다.
이 수치는 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
# 줄 처리
end
이것은 조기에 루프를 종료할 수 있는 조건이 있는 경우 바람직할 수 있습니다.
메모리뿐 아니라 관심 없는 줄을 처리하는 데 소모되는 CPU 및 I/O의 불필요한 시간도 절약합니다.
안티 패턴
이것은 안티 패턴의 모음으로, 프로덕션 환경에서 측정 가능하고, 중대한 긍정적인 영향을 미쳐야 하는 변경이 아닌 한 피해야 합니다.
상수로 할당 이동하기
객체를 상수로 저장하여 한 번만 할당하는 것은 성능을 향상시킬 수 있지만, 이는 보장되지 않습니다. 상수를 조회하는 것은 실행 시간 성능에 영향을 미치며, 따라서 객체를 직접 참조하는 대신 상수를 사용하는 것은 코드 성능을 저하시킬 수 있습니다. 예를 들어:
SOME_CONSTANT = 'foo'.freeze
9000.times do
SOME_CONSTANT
end
이렇게 해야 하는 유일한 이유는 누군가가 전역 문자열을 변경하지 못하도록 하는 것입니다. 그러나 Ruby에서는 상수를 다시 할당할 수 있으므로 누군가 코드의 다른 곳에서 다음을 수행하는 것을 막을 수 없습니다:
SOME_CONSTANT = 'bar'
수백만 개의 행으로 데이터베이스를 시드하는 방법
예를 들어, 상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 수백만 개의 프로젝트 행이 필요할 수 있습니다. SQL 명령어로 수동으로 하거나 대량 삽입 Rails 모델 기능을 사용하여 이를 수행할 수 있습니다.
ActiveRecord 모델로 작업하고 있다고 가정할 때, 다음 링크가 도움이 될 수 있습니다:
예시
이 스니펫에서 유용한 예시를 찾을 수 있습니다.
ExclusiveLease
Gitlab::ExclusiveLease
는 개발자가 분산 서버 간의 상호 배제를 달성할 수 있도록 해주는 Redis 기반의 잠금 메커니즘입니다. 개발자가 사용할 수 있는 여러 래퍼가 제공됩니다:
-
Gitlab::ExclusiveLeaseHelpers
모듈은 잠금을 만료할 수 있을 때까지 프로세스 또는 스레드를 차단하는 도우미 메서드를 제공합니다. -
ExclusiveLease::Guard
모듈은 실행 중인 코드 블록에 대한 독점 잠금을 얻는 데 도움을 줍니다.
데이터베이스 트랜잭션 내부에서 ExclusiveLease
를 사용하면 안 됩니다. 느린 Redis I/O로 인해 유휴 트랜잭션 기간이 늘어날 수 있기 때문입니다. .try_obtain
메서드는 잠금 시도가 데이터베이스 트랜잭션 내에 있는지 확인하며, 예외를 Sentry와 log/exceptions_json.log
에 추적합니다.
테스트 또는 개발 환경에서는 데이터베이스 트랜잭션 내의 모든 잠금 시도가 Gitlab::ExclusiveLease::LeaseWithinTransactionError
를 발생시키지만, 이는 Gitlab::ExclusiveLease.skipping_transaction_check
블록 내에서 수행된 경우를 제외합니다. 가능한 경우 스킵 기능을 사양에서만 사용하고, 이해를 돕기 위해 잠금에 최대한 가깝게 배치해야 합니다. 사양을 DRY하게 유지하기 위해 트랜잭션 체크 스킵이 재사용되는 코드베이스의 두 부분이 있습니다:
-
Users::Internal
은let_it_be
에서 봇 생성에 대한 트랜잭션 체크를 건너뛰도록 패치됩니다. -
:deploy_key
에 대한FactoryBot
팩토리는DeployKey
모델 생성 중에 트랜잭션을 건너뜁니다.
비사양 또는 비정형 파일 내에서 Gitlab::ExclusiveLease.skipping_transaction_check
를 사용하는 경우 제거 계획을 위한 인프라 개발 이슈에 대한 링크를 포함해야 합니다.