Skip to content

Commit 36228ce

Browse files
authored
Use depends_on for container startup order (refactored) (#593)
* Use depends_on for container startup order * Simplify test * Improve logging, comments and variable names * change subprocess.run to subprocess.check_output * Remove waiting loop and raise error instead of warning * simplify usage scenarios for tests * Prevent cycle dependencies * Add waiting loop again and make max waiting time configurable * Add check if unsupported depends_on long form is used * Change location of wait_time_dependencies in config * Fix depends_on cycle detection * Add depends_on to schema for check * Refactor ordering of services
1 parent 86cd45a commit 36228ce

8 files changed

+210
-6
lines changed

config.yml.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ measurement:
4444
idle-time-end: 5
4545
flow-process-runtime: 3800
4646
phase-transition-time: 1
47+
boot:
48+
wait_time_dependencies: 20
4749
metric-providers:
4850

4951
# Please select the needed providers according to the working ones on your system

lib/schema_checker.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def check_usage_scenario(self, usage_scenario):
9898
Optional("networks"): self.single_or_list(Use(self.contains_no_invalid_chars)),
9999
Optional("environment"): self.single_or_list(Or(dict,str)),
100100
Optional("ports"): self.single_or_list(Or(str, int)),
101+
Optional("depends_on"): Or([str],dict),
101102
Optional("setup-commands"): [str],
102103
Optional("volumes"): self.single_or_list(str),
103104
Optional("folder-destination"):str,

runner.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import random
2222
import shutil
2323
import yaml
24+
from collections import OrderedDict
2425

2526

2627
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -643,18 +644,50 @@ def setup_networks(self):
643644
self.__networks.append(network)
644645
self.__join_default_network = True
645646

647+
def order_services(self, services):
648+
names_ordered = []
649+
def order_service_names(service_name, visited=None):
650+
if visited is None:
651+
visited = set()
652+
if service_name in visited:
653+
raise RuntimeError(f"Cycle found in depends_on definition with service '{service_name}'!")
654+
visited.add(service_name)
655+
656+
service = services[service_name]
657+
if 'depends_on' in service:
658+
if isinstance(service['depends_on'], dict):
659+
raise RuntimeError(f"Service definition of {service_name} uses the long form of 'depends_on', however, GMT only supports the short form!")
660+
for dep in service['depends_on']:
661+
if dep not in names_ordered:
662+
order_service_names(dep, visited)
663+
664+
if service_name not in names_ordered:
665+
names_ordered.append(service_name)
666+
667+
# Iterate over all services and sort them with the recursive function 'order_service_names'
668+
for service_name in services.keys():
669+
order_service_names(service_name)
670+
print("Startup order: ", names_ordered)
671+
return OrderedDict((key, services[key]) for key in names_ordered)
672+
646673
def setup_services(self):
674+
config = GlobalConfig().config
675+
print(TerminalColors.HEADER, '\nSetting up services', TerminalColors.ENDC)
647676
# technically the usage_scenario needs no services and can also operate on an empty list
648677
# This use case is when you have running containers on your host and want to benchmark some code running in them
649-
for service_name in self._usage_scenario.get('services', []):
650-
print(TerminalColors.HEADER, '\nSetting up containers', TerminalColors.ENDC)
678+
services = self._usage_scenario.get('services', {})
651679

652-
if 'container_name' in self._usage_scenario['services'][service_name]:
653-
container_name = self._usage_scenario['services'][service_name]['container_name']
680+
# Check if there are service dependencies defined with 'depends_on'.
681+
# If so, change the order of the services accordingly.
682+
services_ordered = self.order_services(services)
683+
for service_name, service in services_ordered.items():
684+
685+
if 'container_name' in service:
686+
container_name = service['container_name']
654687
else:
655688
container_name = service_name
656689

657-
service = self._usage_scenario['services'][service_name]
690+
print(TerminalColors.HEADER, '\nSetting up container: ', container_name, TerminalColors.ENDC)
658691

659692
print('Resetting container')
660693
# By using the -f we return with 0 if no container is found
@@ -801,10 +834,38 @@ def setup_services(self):
801834
if 'cmd' in service: # must come last
802835
docker_run_string.append(service['cmd'])
803836

837+
# Before starting the container, check if the dependent containers are "ready".
838+
# If not, wait for some time. If the container is not ready after a certain time, throw an error.
839+
# Currently we consider "ready" only as "running".
840+
# In the future we want to implement an health check to know if dependent containers are actually ready.
841+
if 'depends_on' in service:
842+
for dependent_container in service['depends_on']:
843+
time_waited = 0
844+
state = ""
845+
max_waiting_time = config['measurement']['boot']['wait_time_dependencies']
846+
while time_waited < max_waiting_time:
847+
# TODO: Check health status instead if `healthcheck` is enabled (https://github.com/green-coding-berlin/green-metrics-tool/issues/423)
848+
# This waiting loop is actually a pre-work for the upcoming health check. For the check if the container is "running", as implemented here, the waiting loop is not needed.
849+
status_output = subprocess.check_output(
850+
["docker", "container", "inspect", "-f", "{{.State.Status}}", dependent_container],
851+
stderr=subprocess.STDOUT,
852+
text=True
853+
)
854+
state = status_output.strip()
855+
if state == "running":
856+
break;
857+
else:
858+
print(f"State of container '{dependent_container}': {state}. Waiting for 1 second")
859+
self.custom_sleep(1)
860+
time_waited += 1
861+
862+
if state != "running":
863+
raise RuntimeError(f"Dependent container '{dependent_container}' of '{container_name}' is not running after waiting for {time_waited} sec! Consider checking your service configuration, the entrypoint of the container or the logs of the container.")
864+
804865
print(f"Running docker run with: {' '.join(docker_run_string)}")
805866

806867
# docker_run_string must stay as list, cause this forces items to be quoted and escaped and prevents
807-
# injection of unwawnted params
868+
# injection of unwanted params
808869

809870
ps = subprocess.run(
810871
docker_run_string,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
name: Test depends_on
3+
author: David Kopp
4+
description: test
5+
6+
services:
7+
test-container-1:
8+
image: alpine
9+
depends_on:
10+
- test-container-2
11+
- test-container-3
12+
test-container-2:
13+
image: alpine
14+
test-container-3:
15+
image: alpine
16+
depends_on:
17+
- test-container-4
18+
test-container-4:
19+
image: alpine
20+
depends_on:
21+
- test-container-2
22+
23+
flow:
24+
- name: dummy
25+
container: test-container-1
26+
commands:
27+
- type: console
28+
command: pwd
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
name: Test depends_on
3+
author: David Kopp
4+
description: test
5+
6+
services:
7+
test-container-1:
8+
image: alpine
9+
depends_on:
10+
- test-container-2
11+
test-container-2:
12+
image: alpine
13+
depends_on:
14+
- test-container-1
15+
16+
flow:
17+
- name: Stress
18+
container: test-container-1
19+
commands:
20+
- type: console
21+
command: stress-ng -c 1 -t 1 -q
22+
note: Starting Stress
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
name: Test depends_on
3+
author: David Kopp
4+
description: test
5+
6+
services:
7+
test-container-1:
8+
image: alpine
9+
depends_on:
10+
- test-container-2
11+
test-container-2:
12+
image: hello-world # Container exists immediately after start
13+
14+
flow:
15+
- name: Stress
16+
container: test-container-1
17+
commands:
18+
- type: console
19+
command: stress-ng -c 1 -t 1 -q
20+
note: Starting Stress
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
name: Test depends_on
3+
author: David Kopp
4+
description: test
5+
6+
services:
7+
test-container-1:
8+
image: alpine
9+
depends_on:
10+
test-container-2:
11+
condition: service_started
12+
test-container-2:
13+
image: alpine
14+
15+
flow:
16+
- name: dummy
17+
container: test-container-1
18+
commands:
19+
- type: console
20+
command: pwd

tests/test_usage_scenario.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,56 @@ def get_contents_of_bound_volume(runner):
222222
Tests.cleanup(runner)
223223
return ls
224224

225+
# depends_on: [array] (optional)
226+
# Array of container names to express dependencies
227+
def test_depends_on_order():
228+
out = io.StringIO()
229+
err = io.StringIO()
230+
runner = Tests.setup_runner(usage_scenario='depends_on.yml', dry_run=True)
231+
232+
with redirect_stdout(out), redirect_stderr(err):
233+
try:
234+
Tests.run_until(runner, 'setup_services')
235+
finally:
236+
runner.cleanup()
237+
238+
# Expected order: test-container-2, test-container-4, test-container-3, test-container-1
239+
assert_order(out.getvalue(), "test-container-2", "test-container-4")
240+
assert_order(out.getvalue(), "test-container-4", "test-container-3")
241+
assert_order(out.getvalue(), "test-container-3", "test-container-1")
242+
243+
def assert_order(text, first, second):
244+
index1 = text.find(first)
245+
index2 = text.find(second)
246+
247+
assert index1 != -1 and index2 != -1, \
248+
Tests.assertion_info(f"stdout contain the container names '{first}' and '{second}'.", \
249+
f"stdout doesn't contain '{first}' and/or '{second}'.")
250+
251+
assert index1 < index2, Tests.assertion_info(f'{first} should start first, \
252+
because it is a dependency of {second}.', f'{second} started first')
253+
254+
def test_depends_on_error_not_running():
255+
runner = Tests.setup_runner(usage_scenario='depends_on_error_not_running.yml', dry_run=True)
256+
with pytest.raises(RuntimeError) as e:
257+
Tests.run_until(runner, 'setup_services')
258+
assert "Dependent container 'test-container-2' of 'test-container-1' is not running" in str(e.value) , \
259+
Tests.assertion_info('test-container-2 is not running', str(e.value))
260+
261+
def test_depends_on_error_cyclic_dependency():
262+
runner = Tests.setup_runner(usage_scenario='depends_on_error_cycle.yml', dry_run=True)
263+
with pytest.raises(RuntimeError) as e:
264+
Tests.run_until(runner, 'setup_services')
265+
assert "Cycle found in depends_on definition with service 'test-container-1'" in str(e.value) , \
266+
Tests.assertion_info('cycle in depends_on with test-container-1', str(e.value))
267+
268+
def test_depends_on_error_unsupported_long_form():
269+
runner = Tests.setup_runner(usage_scenario='depends_on_error_unsupported_long_form.yml', dry_run=True)
270+
with pytest.raises(RuntimeError) as e:
271+
Tests.run_until(runner, 'setup_services')
272+
assert "long form" in str(e.value) , \
273+
Tests.assertion_info('long form is not supported', str(e.value))
274+
225275
#volumes: [array] (optional)
226276
#Array of volumes to be mapped. Only read of runner.py is executed with --allow-unsafe flag
227277
def test_volume_bindings_allow_unsafe_true():

0 commit comments

Comments
 (0)