Commit 9e6c9ec4 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Add deadlines based on the request to gitaly

This makes sure that the deadline we set on a gitaly call never
exceeds the request deadline.

We also raise an exception if the request deadline has already been
exceeded by the time we're trying to make a Gitaly call.

These deadlines don't apply when calling gitaly from Sidekiq.

We do this by storing the start time of the request in the request
store on the thread (using the RequestStore).

The maximum request duration defaults to 55 seconds, as the default
worker timeout is 60 seconds. But can be configured through
gitlab.yml.
parent 7c15fffd
...@@ -18,7 +18,7 @@ module RequiresWhitelistedMonitoringClient ...@@ -18,7 +18,7 @@ module RequiresWhitelistedMonitoringClient
# debugging purposes # debugging purposes
return true if Rails.env.development? && request.local? return true if Rails.env.development? && request.local?
ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) } ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) }
end end
def ip_whitelist def ip_whitelist
......
...@@ -169,7 +169,11 @@ class ApplicationSetting < ApplicationRecord ...@@ -169,7 +169,11 @@ class ApplicationSetting < ApplicationRecord
validates :gitaly_timeout_default, validates :gitaly_timeout_default,
presence: true, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 } numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: Settings.gitlab.max_request_duration_seconds
}
validates :gitaly_timeout_medium, validates :gitaly_timeout_medium,
presence: true, presence: true,
......
...@@ -8,6 +8,9 @@ ...@@ -8,6 +8,9 @@
.form-text.text-muted .form-text.text-muted
Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
for git fetch/push operations or Sidekiq jobs. for git fetch/push operations or Sidekiq jobs.
This timeout should be less than the worker timeout. If a Gitaly call timeout would exceed the
worker timeout, the remaining time from the worker timeout would be used to avoid having to terminate
the worker.
.form-group .form-group
= f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold' = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold'
= f.number_field :gitaly_timeout_fast, class: 'form-control' = f.number_field :gitaly_timeout_fast, class: 'form-control'
......
---
title: Don't let Gitaly calls exceed a request time of 55 seconds
merge_request: 21492
author:
type: performance
...@@ -33,6 +33,9 @@ production: &base ...@@ -33,6 +33,9 @@ production: &base
host: localhost host: localhost
port: 80 # Set to 443 if using HTTPS, see installation.md#using-https for additional HTTPS configuration details port: 80 # Set to 443 if using HTTPS, see installation.md#using-https for additional HTTPS configuration details
https: false # Set to true if using HTTPS, see installation.md#using-https for additional HTTPS configuration details https: false # Set to true if using HTTPS, see installation.md#using-https for additional HTTPS configuration details
# The maximum time unicorn/puma can spend on the request. This needs to be smaller than the worker timeout.
# Default is 95% of the worker timeout
max_request_duration: 57
# Uncomment this line below if your ssh host is different from HTTP/HTTPS one # Uncomment this line below if your ssh host is different from HTTP/HTTPS one
# (you'd obviously need to replace ssh.host_example.com with your own host). # (you'd obviously need to replace ssh.host_example.com with your own host).
......
...@@ -209,6 +209,7 @@ Settings.gitlab['content_security_policy'] ||= Gitlab::ContentSecurityPolicy::Co ...@@ -209,6 +209,7 @@ Settings.gitlab['content_security_policy'] ||= Gitlab::ContentSecurityPolicy::Co
Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml')) Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml'))
Settings.gitlab['impersonation_enabled'] ||= true if Settings.gitlab['impersonation_enabled'].nil? Settings.gitlab['impersonation_enabled'] ||= true if Settings.gitlab['impersonation_enabled'].nil?
Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil? Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil?
Settings.gitlab['max_request_duration_seconds'] ||= 57
Gitlab.ee do Gitlab.ee do
Settings.gitlab['mirror_max_delay'] ||= 300 Settings.gitlab['mirror_max_delay'] ||= 300
......
...@@ -37,7 +37,7 @@ unless Gitlab::Runtime.sidekiq? ...@@ -37,7 +37,7 @@ unless Gitlab::Runtime.sidekiq?
payload[:response] = event.payload[:response] if event.payload[:response] payload[:response] = event.payload[:response] if event.payload[:response]
payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id
if cpu_s = Gitlab::Metrics::System.thread_cpu_duration(::Gitlab::RequestContext.start_thread_cpu_time) if cpu_s = Gitlab::Metrics::System.thread_cpu_duration(::Gitlab::RequestContext.instance.start_thread_cpu_time)
payload[:cpu_s] = cpu_s payload[:cpu_s] = cpu_s
end end
......
Rails.application.configure do |config| Rails.application.configure do |config|
config.middleware.insert_after RequestStore::Middleware, Gitlab::RequestContext config.middleware.insert_after RequestStore::Middleware, Gitlab::Middleware::RequestContext
end end
...@@ -8,7 +8,7 @@ module Gitlab ...@@ -8,7 +8,7 @@ module Gitlab
class << self class << self
def limit_user_id!(user_id) def limit_user_id!(user_id)
if config.unique_ips_limit_enabled if config.unique_ips_limit_enabled
ip = RequestContext.client_ip ip = RequestContext.instance.client_ip
unique_ips = update_and_return_ips_count(user_id, ip) unique_ips = update_and_return_ips_count(user_id, ip)
raise TooManyIps.new(user_id, ip, unique_ips) if unique_ips > config.unique_ips_limit_per_user raise TooManyIps.new(user_id, ip, unique_ips) if unique_ips > config.unique_ips_limit_per_user
......
...@@ -160,6 +160,7 @@ module Gitlab ...@@ -160,6 +160,7 @@ module Gitlab
def self.execute(storage, service, rpc, request, remote_storage:, timeout:) def self.execute(storage, service, rpc, request, remote_storage:, timeout:)
enforce_gitaly_request_limits(:call) enforce_gitaly_request_limits(:call)
Gitlab::RequestContext.instance.ensure_deadline_not_exceeded!
kwargs = request_kwargs(storage, timeout: timeout.to_f, remote_storage: remote_storage) kwargs = request_kwargs(storage, timeout: timeout.to_f, remote_storage: remote_storage)
kwargs = yield(kwargs) if block_given? kwargs = yield(kwargs) if block_given?
...@@ -234,12 +235,28 @@ module Gitlab ...@@ -234,12 +235,28 @@ module Gitlab
metadata['gitaly-session-id'] = session_id metadata['gitaly-session-id'] = session_id
metadata.merge!(Feature::Gitaly.server_feature_flags) metadata.merge!(Feature::Gitaly.server_feature_flags)
result = { metadata: metadata } deadline_info = request_deadline(timeout)
metadata.merge!(deadline_info.slice(:deadline_type))
result[:deadline] = real_time + timeout if timeout > 0 { metadata: metadata, deadline: deadline_info[:deadline] }
result
end end
def self.request_deadline(timeout)
# timeout being 0 means the request is allowed to run indefinitely.
# We can't allow that inside a request, but this won't count towards Gitaly
# error budgets
regular_deadline = real_time.to_i + timeout if timeout > 0
return { deadline: regular_deadline } if Sidekiq.server?
return { deadline: regular_deadline } unless Gitlab::RequestContext.instance.request_deadline
limited_deadline = [regular_deadline, Gitlab::RequestContext.instance.request_deadline].compact.min
limited = limited_deadline < regular_deadline
{ deadline: limited_deadline, deadline_type: limited ? "limited" : "regular" }
end
private_class_method :request_deadline
def self.session_id def self.session_id
Gitlab::SafeRequestStore[:gitaly_session_id] ||= SecureRandom.uuid Gitlab::SafeRequestStore[:gitaly_session_id] ||= SecureRandom.uuid
end end
......
# frozen_string_literal: true
module Gitlab
module Middleware
class RequestContext
def initialize(app)
@app = app
end
def call(env)
# We should be using ActionDispatch::Request instead of
# Rack::Request to be consistent with Rails, but due to a Rails
# bug described in
# https://gitlab.com/gitlab-org/gitlab-foss/issues/58573#note_149799010
# hosts behind a load balancer will only see 127.0.0.1 for the
# load balancer's IP.
req = Rack::Request.new(env)
Gitlab::RequestContext.instance.client_ip = req.ip
Gitlab::RequestContext.instance.start_thread_cpu_time = Gitlab::Metrics::System.thread_cpu_time
Gitlab::RequestContext.instance.request_start_time = Gitlab::Metrics::System.real_time
@app.call(env)
end
end
end
end
...@@ -2,34 +2,37 @@ ...@@ -2,34 +2,37 @@
module Gitlab module Gitlab
class RequestContext class RequestContext
class << self include Singleton
def client_ip
Gitlab::SafeRequestStore[:client_ip] RequestDeadlineExceeded = Class.new(StandardError)
end
attr_accessor :client_ip, :start_thread_cpu_time, :request_start_time
def start_thread_cpu_time class << self
Gitlab::SafeRequestStore[:start_thread_cpu_time] def instance
Gitlab::SafeRequestStore[:request_context] ||= new
end end
end end
def initialize(app) def request_deadline
@app = app return unless request_start_time
return unless Feature.enabled?(:request_deadline)
@request_deadline ||= request_start_time + max_request_duration_seconds
end end
def call(env) def ensure_deadline_not_exceeded!
# We should be using ActionDispatch::Request instead of return unless request_deadline
# Rack::Request to be consistent with Rails, but due to a Rails return if Gitlab::Metrics::System.real_time < request_deadline
# bug described in
# https://gitlab.com/gitlab-org/gitlab-foss/issues/58573#note_149799010
# hosts behind a load balancer will only see 127.0.0.1 for the
# load balancer's IP.
req = Rack::Request.new(env)
Gitlab::SafeRequestStore[:client_ip] = req.ip raise RequestDeadlineExceeded,
"Request takes longer than #{max_request_duration_seconds}"
end
Gitlab::SafeRequestStore[:start_thread_cpu_time] = Gitlab::Metrics::System.thread_cpu_time private
@app.call(env) def max_request_duration_seconds
Settings.gitlab.max_request_duration_seconds
end end
end end
end end
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe HealthCheckController do describe HealthCheckController, :request_store do
include StubENV include StubENV
let(:xml_response) { Hash.from_xml(response.body)['hash'] } let(:xml_response) { Hash.from_xml(response.body)['hash'] }
...@@ -18,7 +18,7 @@ describe HealthCheckController do ...@@ -18,7 +18,7 @@ describe HealthCheckController do
describe 'GET #index' do describe 'GET #index' do
context 'when services are up but accessed from outside whitelisted ips' do context 'when services are up but accessed from outside whitelisted ips' do
before do before do
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip) allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(not_whitelisted_ip)
end end
it 'returns a not found page' do it 'returns a not found page' do
...@@ -48,7 +48,7 @@ describe HealthCheckController do ...@@ -48,7 +48,7 @@ describe HealthCheckController do
context 'when services are up and accessed from whitelisted ips' do context 'when services are up and accessed from whitelisted ips' do
before do before do
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip)
end end
it 'supports successful plaintext response' do it 'supports successful plaintext response' do
...@@ -95,7 +95,7 @@ describe HealthCheckController do ...@@ -95,7 +95,7 @@ describe HealthCheckController do
before do before do
allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire') allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire')
allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire') allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire')
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip)
end end
it 'supports failure plaintext response' do it 'supports failure plaintext response' do
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe MetricsController do describe MetricsController, :request_store do
include StubENV include StubENV
let(:metrics_multiproc_dir) { @metrics_multiproc_dir } let(:metrics_multiproc_dir) { @metrics_multiproc_dir }
...@@ -53,7 +53,7 @@ describe MetricsController do ...@@ -53,7 +53,7 @@ describe MetricsController do
context 'accessed from whitelisted ip' do context 'accessed from whitelisted ip' do
before do before do
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip)
end end
it_behaves_like 'endpoint providing metrics' it_behaves_like 'endpoint providing metrics'
...@@ -61,7 +61,7 @@ describe MetricsController do ...@@ -61,7 +61,7 @@ describe MetricsController do
context 'accessed from ip in whitelisted range' do context 'accessed from ip in whitelisted range' do
before do before do
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip_in_whitelisted_range) allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(ip_in_whitelisted_range)
end end
it_behaves_like 'endpoint providing metrics' it_behaves_like 'endpoint providing metrics'
...@@ -69,7 +69,7 @@ describe MetricsController do ...@@ -69,7 +69,7 @@ describe MetricsController do
context 'accessed from not whitelisted ip' do context 'accessed from not whitelisted ip' do
before do before do
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip) allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(not_whitelisted_ip)
end end
it 'returns the expected error response' do it 'returns the expected error response' do
......
...@@ -229,6 +229,59 @@ describe Gitlab::GitalyClient do ...@@ -229,6 +229,59 @@ describe Gitlab::GitalyClient do
end end
end end
end end
context 'deadlines', :request_store do
let(:request_deadline) { real_time + 10.0 }
before do
allow(Gitlab::RequestContext.instance).to receive(:request_deadline).and_return(request_deadline)
end
it 'includes the deadline information' do
kword_args = described_class.request_kwargs('default', timeout: 2)
expect(kword_args[:deadline])
.to be_within(1).of(real_time + 2)
expect(kword_args[:metadata][:deadline_type]).to eq("regular")
end
it 'limits the deadline do the request deadline if that is closer', :aggregate_failures do
kword_args = described_class.request_kwargs('default', timeout: 15)
expect(kword_args[:deadline]).to eq(request_deadline)
expect(kword_args[:metadata][:deadline_type]).to eq("limited")
end
it 'does not limit calls in sidekiq' do
expect(Sidekiq).to receive(:server?).and_return(true)
kword_args = described_class.request_kwargs('default', timeout: 6.hours.to_i)
expect(kword_args[:deadline]).to be_within(1).of(real_time + 6.hours.to_i)
expect(kword_args[:metadata][:deadline_type]).to be_nil
end
it 'does not limit calls in sidekiq when allowed unlimited' do
expect(Sidekiq).to receive(:server?).and_return(true)
kword_args = described_class.request_kwargs('default', timeout: 0)
expect(kword_args[:deadline]).to be_nil
expect(kword_args[:metadata][:deadline_type]).to be_nil
end
it 'includes only the deadline specified by the timeout when there was no deadline' do
allow(Gitlab::RequestContext.instance).to receive(:request_deadline).and_return(nil)
kword_args = described_class.request_kwargs('default', timeout: 6.hours.to_i)
expect(kword_args[:deadline]).to be_within(1).of(Gitlab::Metrics::System.real_time + 6.hours.to_i)
expect(kword_args[:metadata][:deadline_type]).to be_nil
end
def real_time
Gitlab::Metrics::System.real_time
end
end
end end
describe 'enforce_gitaly_request_limits?' do describe 'enforce_gitaly_request_limits?' do
......
# frozen_string_literal: true
require 'fast_spec_helper'
require 'rack'
require 'request_store'
require_relative '../../../support/helpers/next_instance_of'
describe Gitlab::Middleware::RequestContext do
include NextInstanceOf
let(:app) { -> (env) {} }
let(:env) { {} }
around do |example|
RequestStore.begin!
example.run
RequestStore.end!
RequestStore.clear!
end
describe '#call' do
context 'setting the client ip' do
subject { Gitlab::RequestContext.instance.client_ip }
context 'with X-Forwarded-For headers' do
let(:load_balancer_ip) { '1.2.3.4' }
let(:headers) do
{
'HTTP_X_FORWARDED_FOR' => "#{load_balancer_ip}, 127.0.0.1",
'REMOTE_ADDR' => '127.0.0.1'
}
end
let(:env) { Rack::MockRequest.env_for("/").merge(headers) }
it 'returns the load balancer IP' do
endpoint = proc do
[200, {}, ["Hello"]]
end
described_class.new(endpoint).call(env)
expect(subject).to eq(load_balancer_ip)
end
end
context 'request' do
let(:ip) { '192.168.1.11' }
before do
allow_next_instance_of(Rack::Request) do |instance|
allow(instance).to receive(:ip).and_return(ip)
end
described_class.new(app).call(env)
end
it { is_expected.to eq(ip) }
end
context 'before RequestContext middleware run' do
it { is_expected.to be_nil }
end
end
end
context 'setting the thread cpu time' do
it 'sets the `start_thread_cpu_time`' do
expect { described_class.new(app).call(env) }
.to change { Gitlab::RequestContext.instance.start_thread_cpu_time }.from(nil).to(Float)
end
end
context 'setting the request start time' do
it 'sets the `request_start_time`' do
expect { described_class.new(app).call(env) }
.to change { Gitlab::RequestContext.instance.request_start_time }.from(nil).to(Float)
end
end
end
...@@ -2,59 +2,44 @@ ...@@ -2,59 +2,44 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::RequestContext do describe Gitlab::RequestContext, :request_store do
describe '#client_ip' do subject { described_class.instance }
subject { described_class.client_ip }
it { is_expected.to have_attributes(client_ip: nil, start_thread_cpu_time: nil, request_start_time: nil) }
let(:app) { -> (env) {} }
let(:env) { Hash.new }
context 'with X-Forwarded-For headers', :request_store do
let(:load_balancer_ip) { '1.2.3.4' }
let(:headers) do
{
'HTTP_X_FORWARDED_FOR' => "#{load_balancer_ip}, 127.0.0.1",
'REMOTE_ADDR' => '127.0.0.1'
}
end
let(:env) { Rack::MockRequest.env_for("/").merge(headers) } describe '#request_deadline' do
let(:request_start_time) { 1575982156.206008 }
it 'returns the load balancer IP' do it "sets the time to #{Settings.gitlab.max_request_duration_seconds} seconds in the future" do
client_ip = nil allow(subject).to receive(:request_start_time).and_return(request_start_time)
endpoint = proc do expect(subject.request_deadline).to eq(1575982156.206008 + Settings.gitlab.max_request_duration_seconds)
client_ip = Gitlab::SafeRequestStore[:client_ip] expect(subject.request_deadline).to be_a(Float)
[200, {}, ["Hello"]]
end end
described_class.new(endpoint).call(env) it 'returns nil if there is no start time' do
allow(subject).to receive(:request_start_time).and_return(nil)
expect(client_ip).to eq(load_balancer_ip) expect(subject.request_deadline).to be_nil
end end
end end
context 'when RequestStore::Middleware is used' do describe '#ensure_request_deadline_not_exceeded!' do
around do |example| it 'does not raise an error when there was no deadline' do
RequestStore::Middleware.new(-> (env) { example.run }).call({}) expect(subject).to receive(:request_deadline).and_return(nil)
expect { subject.ensure_deadline_not_exceeded! }.not_to raise_error
end end
context 'request' do it 'does not raise an error if the deadline is in the future' do
let(:ip) { '192.168.1.11' } allow(subject).to receive(:request_deadline).and_return(Gitlab::Metrics::System.real_time + 10)
before do expect { subject.ensure_deadline_not_exceeded! }.not_to raise_error
allow_next_instance_of(Rack::Request) do |instance|
allow(instance).to receive(:ip).and_return(ip)
end
described_class.new(app).call(env)
end end
it { is_expected.to eq(ip) } it 'raises an error when the deadline is in the past' do
end allow(subject).to receive(:request_deadline).and_return(Gitlab::Metrics::System.real_time - 10)
context 'before RequestContext middleware run' do expect { subject.ensure_deadline_not_exceeded! }.to raise_error(described_class::RequestDeadlineExceeded)
it { is_expected.to be_nil }
end
end end
end end
end end
...@@ -319,6 +319,11 @@ describe ApplicationSetting do ...@@ -319,6 +319,11 @@ describe ApplicationSetting do
end end
context 'gitaly timeouts' do context 'gitaly timeouts' do
it "validates that the default_timeout is lower than the max_request_duration" do
is_expected.to validate_numericality_of(:gitaly_timeout_default)
.is_less_than_or_equal_to(Settings.gitlab.max_request_duration_seconds)
end
[:gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast].each do |timeout_name| [:gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast].each do |timeout_name|
it do it do
is_expected.to validate_presence_of(timeout_name) is_expected.to validate_presence_of(timeout_name)
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
shared_context 'unique ips sign in limit' do shared_context 'unique ips sign in limit' do
include StubENV include StubENV
let(:request_context) { Gitlab::RequestContext.instance }
before do before do
Gitlab::Redis::Cache.with(&:flushall) Gitlab::Redis::Cache.with(&:flushall)
Gitlab::Redis::Queues.with(&:flushall) Gitlab::Redis::Queues.with(&:flushall)
...@@ -15,10 +17,13 @@ shared_context 'unique ips sign in limit' do ...@@ -15,10 +17,13 @@ shared_context 'unique ips sign in limit' do
unique_ips_limit_enabled: true, unique_ips_limit_enabled: true,
unique_ips_limit_time_window: 10000 unique_ips_limit_time_window: 10000
) )
# Make sure we're working with the same reqeust context everywhere
allow(Gitlab::RequestContext).to receive(:instance).and_return(request_context)
end end
def change_ip(ip) def change_ip(ip)
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip) allow(request_context).to receive(:client_ip).and_return(ip)
end end
def request_from_ip(ip) def request_from_ip(ip)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment