Skip to content

Commit 5c83d80

Browse files
authored
feat(autoscale): add autoscaling support to application on per proc type basis (#1018)
1 parent b907ee1 commit 5c83d80

8 files changed

Lines changed: 252 additions & 26 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.10 on 2016-08-30 17:32
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations
6+
import jsonfield.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('api', '0016_auto_20160830_0104'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='appsettings',
18+
name='autoscale',
19+
field=jsonfield.fields.JSONField(blank=True, default={}),
20+
),
21+
]

rootfs/api/models/app.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ def _update_application_service(self, namespace, app_type, port, routable=False,
922922
except Exception as e:
923923
# Fix service to old port and app type
924924
self._scheduler.svc.update(namespace, namespace, data=old_service)
925-
raise KubeException(str(e)) from e
925+
raise ServiceUnavailable(str(e)) from e
926926

927927
def whitelist(self, whitelist):
928928
"""
@@ -936,3 +936,29 @@ def whitelist(self, whitelist):
936936
self._scheduler.svc.update(self.id, self.id, data=service)
937937
except KubeException as e:
938938
raise ServiceUnavailable(str(e)) from e
939+
940+
def autoscale(self, proc_type, autoscale):
941+
"""
942+
Set autoscale rules for the application
943+
"""
944+
name = '{}-{}'.format(self.id, proc_type)
945+
# basically fake out a Deployment object (only thing we use) to assign to the HPA
946+
target = {'kind': 'Deployment', 'metadata': {'name': name}}
947+
948+
try:
949+
# get the target for autoscaler, in this case Deployment
950+
self._scheduler.hpa.get(self.id, name)
951+
if autoscale is None:
952+
self._scheduler.hpa.delete(self.id, name)
953+
else:
954+
self._scheduler.hpa.update(
955+
self.id, name, proc_type, target, **autoscale
956+
)
957+
except KubeHTTPException as e:
958+
if e.response.status_code == 404:
959+
self._scheduler.hpa.create(
960+
self.id, name, proc_type, target, **autoscale
961+
)
962+
else:
963+
# let the user know about any other errors
964+
raise ServiceUnavailable(str(e)) from e

rootfs/api/models/appsettings.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
from django.conf import settings
33
from django.db import models
44
from django.contrib.postgres.fields import ArrayField
5+
from jsonfield import JSONField
6+
from rest_framework.exceptions import NotFound
57

8+
from api.utils import dict_diff
69
from api.models import UuidAuditedModel
7-
from api.exceptions import DeisException, AlreadyExists
10+
from api.exceptions import DeisException, AlreadyExists, UnprocessableEntity
811

912

1013
class AppSettings(UuidAuditedModel):
@@ -17,6 +20,7 @@ class AppSettings(UuidAuditedModel):
1720
maintenance = models.NullBooleanField(default=None)
1821
routable = models.NullBooleanField(default=None)
1922
whitelist = ArrayField(models.CharField(max_length=50), default=[])
23+
autoscale = JSONField(default={}, blank=True)
2024

2125
class Meta:
2226
get_latest_by = 'created'
@@ -86,6 +90,46 @@ def update_whitelist(self, previous_settings):
8690
self.summary += ' and '
8791
self.summary += "{} {}".format(self.owner, changes)
8892

93+
def update_autoscale(self, previous_settings):
94+
data = getattr(previous_settings, 'autoscale', {}).copy()
95+
new = getattr(self, 'autoscale', {}).copy()
96+
# If no previous settings then do nothing
97+
if not previous_settings:
98+
return
99+
100+
# if nothing changed copy the settings from previous
101+
if not new and data:
102+
setattr(self, 'autoscale', data)
103+
elif data != new:
104+
for proc, scale in new.items():
105+
if scale is None:
106+
# error if unsetting non-existing key
107+
if proc not in data:
108+
raise UnprocessableEntity('{} does not exist under {}'.format(proc, 'autoscale')) # noqa
109+
del data[proc]
110+
else:
111+
data[proc] = scale
112+
setattr(self, 'autoscale', data)
113+
114+
# only apply new items
115+
for proc, scale in new.items():
116+
self.app.autoscale(proc, scale)
117+
118+
# if the autoscale information changed, log the dict diff
119+
changes = []
120+
old_autoscale = getattr(previous_settings, 'autoscale', {})
121+
diff = dict_diff(self.autoscale, old_autoscale)
122+
# try to be as succinct as possible
123+
added = ', '.join(list(map(lambda x: 'default' if x == '' else x, [k for k in diff.get('added', {})]))) # noqa
124+
added = 'added autoscale for process type ' + added if added else ''
125+
changed = ', '.join(list(map(lambda x: 'default' if x == '' else x, [k for k in diff.get('changed', {})]))) # noqa
126+
changed = 'changed autoscale for process type ' + changed if changed else ''
127+
deleted = ', '.join(list(map(lambda x: 'default' if x == '' else x, [k for k in diff.get('deleted', {})]))) # noqa
128+
deleted = 'deleted autoscale for process type ' + deleted if deleted else ''
129+
changes = ', '.join(i for i in (added, changed, deleted) if i)
130+
if changes:
131+
self.summary += ["{} {}".format(self.owner, changes)]
132+
89133
def save(self, *args, **kwargs):
90134
self.summary = []
91135
previous_settings = None
@@ -98,6 +142,9 @@ def save(self, *args, **kwargs):
98142
self.update_maintenance(previous_settings)
99143
self.update_routable(previous_settings)
100144
self.update_whitelist(previous_settings)
145+
self.update_autoscale(previous_settings)
146+
except (UnprocessableEntity, NotFound):
147+
raise
101148
except Exception as e:
102149
self.delete()
103150
raise DeisException(str(e)) from e

rootfs/api/serializers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ class AppSettingsSerializer(serializers.ModelSerializer):
470470

471471
app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all())
472472
owner = serializers.ReadOnlyField(source='owner.username')
473+
autoscale = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
473474

474475
class Meta:
475476
"""Metadata options for a :class:`AppSettingsSerializer`."""
@@ -489,6 +490,35 @@ def validate_whitelist(self, data):
489490
except:
490491
raise serializers.ValidationError(
491492
"The address {} is not valid".format(address))
493+
494+
return data
495+
496+
def validate_autoscale(self, data):
497+
schema = {
498+
"$schema": "http://json-schema.org/schema#",
499+
"type": "object",
500+
"properties": {
501+
# minimum replicas autoscale will keep resource at based on load
502+
"min": {"type": "integer"},
503+
# maximum replicas autoscale will keep resource at based on load
504+
"max": {"type": "integer"},
505+
# how much CPU load there is to trigger scaling rules
506+
"cpu_percent": {"type": "integer"},
507+
},
508+
"required": ["min", "max", "cpu_percent"],
509+
}
510+
511+
for proc, autoscale in data.items():
512+
if autoscale is None:
513+
continue
514+
515+
try:
516+
jsonschema.validate(autoscale, schema)
517+
except jsonschema.ValidationError as e:
518+
raise serializers.ValidationError(
519+
"could not validate {}: {}".format(autoscale, e.message)
520+
)
521+
492522
return data
493523

494524

rootfs/api/tests/test_app_settings.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from api.models import App
88
from unittest import mock
99
from scheduler import KubeException
10-
from api.tests import adapter, DeisTransactionTestCase
10+
from api.tests import adapter, mock_port, DeisTransactionTestCase
1111

1212

1313
@requests_mock.Mocker(real_http=True, adapter=adapter)
14+
@mock.patch('api.models.release.publish_release', lambda *args: None)
15+
@mock.patch('api.models.release.docker_get_port', mock_port)
1416
class TestAppSettings(DeisTransactionTestCase):
1517
"""Tests setting and updating config values"""
1618

@@ -168,3 +170,83 @@ def test_kubernetes_service_failure(self, mock_requests):
168170
url = '/v2/apps/{}/whitelist'.format(app_id)
169171
response = self.client.post(url, {'addresses': addresses})
170172
self.assertEqual(response.status_code, 400, response.data)
173+
174+
def test_autoscale(self, mock_requests):
175+
"""
176+
Test that autoscale can be applied
177+
"""
178+
app_id = self.create_app()
179+
180+
# create an autoscaling rule
181+
scale = {'autoscale': {'cmd': {'min': 2, 'max': 5, 'cpu_percent': 45}}}
182+
response = self.client.post(
183+
'/v2/apps/{app_id}/settings'.format(**locals()),
184+
scale
185+
)
186+
self.assertEqual(response.status_code, 201, response.data)
187+
self.assertIn('cmd', response.data['autoscale'])
188+
self.assertEqual(response.data['autoscale'], scale['autoscale'])
189+
190+
# update
191+
scale = {'autoscale': {'cmd': {'min': 2, 'max': 8, 'cpu_percent': 45}}}
192+
response = self.client.post(
193+
'/v2/apps/{app_id}/settings'.format(**locals()),
194+
scale
195+
)
196+
self.assertEqual(response.status_code, 201, response.data)
197+
self.assertIn('cmd', response.data['autoscale'])
198+
self.assertEqual(response.data['autoscale'], scale['autoscale'])
199+
200+
# create
201+
scale = {'autoscale': {'worker': {'min': 2, 'max': 5, 'cpu_percent': 45}}}
202+
response = self.client.post(
203+
'/v2/apps/{app_id}/settings'.format(**locals()),
204+
scale
205+
)
206+
self.assertEqual(response.status_code, 201, response.data)
207+
self.assertIn('worker', response.data['autoscale'])
208+
self.assertEqual(response.data['autoscale']['worker'], scale['autoscale']['worker'])
209+
210+
# check that the cmd proc type is still there
211+
self.assertIn('cmd', response.data['autoscale'])
212+
213+
# check that config fails if trying to unset non-existing proc type
214+
response = self.client.post(
215+
'/v2/apps/{app_id}/settings'.format(**locals()),
216+
{'autoscale': {'invalid_proctype': None}})
217+
self.assertEqual(response.status_code, 422, response.data)
218+
219+
# remove a proc type
220+
response = self.client.post(
221+
'/v2/apps/{app_id}/settings'.format(**locals()),
222+
{'autoscale': {'worker': None}})
223+
self.assertEqual(response.status_code, 201, response.data)
224+
self.assertNotIn('worker', response.data['autoscale'])
225+
self.assertIn('cmd', response.data['autoscale'])
226+
227+
# remove another proc type
228+
response = self.client.post(
229+
'/v2/apps/{app_id}/settings'.format(**locals()),
230+
{'autoscale': {'cmd': None}})
231+
self.assertEqual(response.status_code, 201, response.data)
232+
self.assertNotIn('cmd', response.data['autoscale'])
233+
234+
def test_autoscale_validations(self, mock_requests):
235+
"""
236+
Test that autoscale validations work
237+
"""
238+
app_id = self.create_app()
239+
240+
# Set one of the values that require a numeric value to a string
241+
response = self.client.post(
242+
'/v2/apps/{app_id}/settings'.format(**locals()),
243+
{'autoscale': {'cmd': {'min': 4, 'max': 5, 'cpu_percent': "t"}}}
244+
)
245+
self.assertEqual(response.status_code, 400, response.data)
246+
247+
# Don't set one of the mandatory value
248+
response = self.client.post(
249+
'/v2/apps/{app_id}/settings'.format(**locals()),
250+
{'autoscale': {'cmd': {'min': 4, 'cpu_percent': 45}}}
251+
)
252+
self.assertEqual(response.status_code, 400, response.data)

rootfs/scheduler/mock.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,12 @@ def process_hpa():
124124
hpa = cache.get(row)
125125

126126
# check if the resource referenced actually exists
127-
kind = hpa['spec']['scaleRef']['kind'].lower() + 's' # make plural
128-
name = hpa['spec']['scaleRef']['name'].lower()
127+
if 'scaleRef' in hpa['spec']:
128+
kind = hpa['spec']['scaleRef']['kind'].lower() + 's' # make plural
129+
name = hpa['spec']['scaleRef']['name'].lower()
130+
else:
131+
kind = hpa['spec']['scaleTargetRef']['kind'].lower() + 's' # make plural
132+
name = hpa['spec']['scaleTargetRef']['name'].lower()
129133
deployment = None
130134
for deploy in cache.get(kind, []):
131135
item = cache.get(deploy)

rootfs/scheduler/resources/horizontalpodautoscaler.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def get(self, namespace, name=None, **kwargs):
3838

3939
return response
4040

41-
def manifest(self, namespace, name, target, **kwargs):
41+
def manifest(self, namespace, name, app_type, target, **kwargs):
4242
min_replicas = kwargs.get('min')
4343
max_replicas = kwargs.get('max')
4444
cpu_percent = kwargs.get('cpu_percent')
@@ -51,7 +51,7 @@ def manifest(self, namespace, name, target, **kwargs):
5151

5252
labels = {
5353
'app': namespace,
54-
'type': kwargs.get('app_type'),
54+
'type': app_type,
5555
'heritage': 'deis',
5656
}
5757

@@ -66,41 +66,53 @@ def manifest(self, namespace, name, target, **kwargs):
6666
'spec': {
6767
'minReplicas': min_replicas,
6868
'maxReplicas': max_replicas,
69-
'scaleRef': {
70-
# only works with Deployments, RS and RC
71-
'kind': target['kind'],
72-
'name': target['metadata']['name'],
73-
# the resource of the above which does the scale action
74-
'subresource': 'scale',
75-
},
76-
'cpuUtilization': {
77-
'targetPercentage': cpu_percent
78-
}
7969
}
8070
}
8171

72+
if self.version() >= 1.3:
73+
manifest['spec']['targetCPUUtilizationPercentage'] = cpu_percent
74+
75+
manifest['spec']['scaleTargetRef'] = {
76+
# only works with Deployments, RS and RC
77+
'kind': target['kind'],
78+
'name': target['metadata']['name'],
79+
}
80+
elif self.version() <= 1.2:
81+
# api changed between version
82+
manifest['spec']['cpuUtilization'] = {
83+
'targetPercentage': cpu_percent
84+
}
85+
86+
manifest['spec']['scaleRef'] = {
87+
# only works with Deployments, RS and RC
88+
'kind': target['kind'],
89+
'name': target['metadata']['name'],
90+
# the resource of the above which does the scale action
91+
'subresource': 'scale',
92+
}
93+
8294
return manifest
8395

84-
def create(self, namespace, name, target, **kwargs):
85-
manifest = self.manifest(namespace, name, target, **kwargs)
96+
def create(self, namespace, name, app_type, target, **kwargs):
97+
manifest = self.manifest(namespace, name, app_type, target, **kwargs)
8698

8799
url = self.api("/namespaces/{}/horizontalpodautoscalers", namespace)
88100
response = self.session.post(url, json=manifest)
89101
if self.unhealthy(response.status_code):
102+
self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG') # noqa
90103
raise KubeHTTPException(
91104
response,
92105
'create HorizontalPodAutoscaler "{}" in Namespace "{}"', name, namespace
93106
)
94-
self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG') # noqa
95107

96108
# optionally wait for HPA if requested
97109
if kwargs.get('wait', False):
98110
self.wait(namespace, name)
99111

100112
return response
101113

102-
def update(self, namespace, name, target, **kwargs):
103-
manifest = self.manifest(namespace, name, target, **kwargs)
114+
def update(self, namespace, name, app_type, target, **kwargs):
115+
manifest = self.manifest(namespace, name, app_type, target, **kwargs)
104116

105117
url = self.api("/namespaces/{}/horizontalpodautoscalers/{}", namespace, name)
106118
response = self.session.put(url, json=manifest)
@@ -137,8 +149,12 @@ def wait(self, namespace, name):
137149
# ideally it would use the resources wait commands but they vary
138150
for _ in range(30):
139151
# fetch resource attached to it
140-
resource_kind = hpa['spec']['scaleRef']['kind'].lower()
141-
resource_name = hpa['spec']['scaleRef']['name']
152+
if self.version() >= 1.3:
153+
resource_kind = hpa['spec']['scaleTargetRef']['kind'].lower()
154+
resource_name = hpa['spec']['scaleTargetRef']['name']
155+
elif self.version() <= 1.2:
156+
resource_kind = hpa['spec']['scaleRef']['kind'].lower()
157+
resource_name = hpa['spec']['scaleRef']['name']
142158

143159
resource = getattr(self, resource_kind)
144160
resource = getattr(resource, 'get')(namespace, resource_name).json()

0 commit comments

Comments
 (0)