Skip unnecessary image uploads

This modifies the container image upload to avoid doing an upload when
the destination image already exists and is identical to the source
image.

This is done by doing a skopeo inspect on the source and destination
images and compares their digest hash.

In RDO Cloud, a full upload takes around 8 minutes. With this change
and an already populated local registry it takes less than 10 seconds.
The extra 2 skopeo inspect calls per upload doesn't measurably impact
the full upload time.

This makes the upload fast enough to run within the main deploy
workflow on every run (which will take more work because the upload
yaml file needs to be stored in the plan).

Change-Id: Ic9422b0ca168b4405cb1973f4e33c91b0f69f02c
Closes-Bug: #1738668
This commit is contained in:
Steve Baker 2018-01-05 13:59:36 +13:00
parent 32b48af416
commit 18a1349a77
2 changed files with 125 additions and 15 deletions

View File

@ -149,7 +149,8 @@ class DockerImageUploader(ImageUploader):
self.insecure_registries = set()
@staticmethod
def upload_image(image_name, pull_source, push_destination):
def upload_image(image_name, pull_source, push_destination,
insecure_registries):
LOG.info('imagename: %s' % image_name)
dockerc = Client(base_url='unix://var/run/docker.sock', version='auto')
if ':' in image_name:
@ -163,13 +164,17 @@ class DockerImageUploader(ImageUploader):
else:
repo = image
DockerImageUploader._pull_retry(dockerc, repo, tag=tag)
full_image = repo + ':' + tag
new_repo = push_destination + '/' + repo.partition('/')[2]
full_new_repo = new_repo + ':' + tag
if DockerImageUploader._images_match(full_image, full_new_repo,
insecure_registries):
LOG.info('Skipping upload for image %s' % image_name)
return []
DockerImageUploader._pull_retry(dockerc, repo, tag=tag)
response = dockerc.tag(image=full_image, repository=new_repo,
tag=tag, force=True)
LOG.debug(response)
@ -178,7 +183,7 @@ class DockerImageUploader(ImageUploader):
tag=tag, stream=True)]
LOG.debug(response)
LOG.info('Completed upload for docker image %s' % image_name)
LOG.info('Completed upload for image %s' % image_name)
return full_image, full_new_repo
@staticmethod
@ -206,6 +211,31 @@ class DockerImageUploader(ImageUploader):
time.sleep(3)
LOG.warning('retrying pulling image: %s' % image)
@staticmethod
def _images_match(image1, image2, insecure_registries):
try:
image1_digest = DockerImageUploader._image_digest(
image1, insecure_registries)
except Exception:
return False
try:
image2_digest = DockerImageUploader._image_digest(
image2, insecure_registries)
except Exception:
return False
# missing digest, no way to know if they match
if not image1_digest or not image2_digest:
return False
return image1_digest == image2_digest
@staticmethod
def _image_digest(image, insecure_registries):
image_url = DockerImageUploader._image_to_url(image)
insecure = image_url.netloc in insecure_registries
i = DockerImageUploader._inspect(image_url.geturl(), insecure)
return i.get('Digest')
@staticmethod
def _inspect(image, insecure=False):
@ -217,7 +247,8 @@ class DockerImageUploader(ImageUploader):
LOG.info('Running %s' % ' '.join(cmd))
env = os.environ.copy()
process = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE)
process = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
out, err = process.communicate()
if process.returncode != 0:
@ -298,7 +329,14 @@ class DockerImageUploader(ImageUploader):
LOG.warning(e)
def add_upload_task(self, image_name, pull_source, push_destination):
self.upload_tasks.append((image_name, pull_source, push_destination))
# prime self.insecure_registries
if pull_source:
self.is_insecure_registry(self._image_to_url(pull_source).netloc)
else:
self.is_insecure_registry(self._image_to_url(image_name).netloc)
self.is_insecure_registry(self._image_to_url(push_destination).netloc)
self.upload_tasks.append((image_name, pull_source, push_destination,
self.insecure_registries))
def run_tasks(self):
if not self.upload_tasks:

View File

@ -46,11 +46,17 @@ class TestImageUploadManager(base.TestCase):
@mock.patch('tripleo_common.image.base.open',
mock.mock_open(read_data=filedata), create=True)
@mock.patch('tripleo_common.image.image_uploader.'
'DockerImageUploader.is_insecure_registry',
return_value=True)
@mock.patch('tripleo_common.image.image_uploader.'
'DockerImageUploader._images_match',
return_value=False)
@mock.patch('os.path.isfile', return_value=True)
@mock.patch('fcntl.ioctl', side_effect=Exception)
@mock.patch('tripleo_common.image.image_uploader.Client')
def test_file_parsing(self, mockdocker, mockioctl, mockpath):
print(filedata)
def test_file_parsing(self, mockdocker, mockioctl, mockpath,
mock_images_match, mock_is_insecure):
manager = image_uploader.ImageUploadManager(self.filelist, debug=True)
parsed_data = manager.upload()
mockpath(self.filelist[0])
@ -111,7 +117,23 @@ class TestDockerImageUploader(base.TestCase):
super(TestDockerImageUploader, self).tearDown()
self.patcher.stop()
def test_upload_image(self):
@mock.patch('subprocess.Popen')
def test_upload_image(self, mock_popen):
result1 = {
'Digest': 'a'
}
result2 = {
'Digest': 'b'
}
mock_process = mock.Mock()
mock_process.communicate.side_effect = [
(json.dumps(result1), ''),
(json.dumps(result2), ''),
]
mock_process.returncode = 0
mock_popen.return_value = mock_process
image = 'docker.io/tripleoupstream/heat-docker-agents-centos'
tag = 'latest'
push_destination = 'localhost:8787'
@ -119,7 +141,8 @@ class TestDockerImageUploader(base.TestCase):
self.uploader.upload_image(image + ':' + tag,
None,
push_destination)
push_destination,
set())
self.dockermock.assert_called_once_with(
base_url='unix://var/run/docker.sock', version='auto')
@ -130,11 +153,12 @@ class TestDockerImageUploader(base.TestCase):
image=image + ':' + tag,
repository=push_image,
tag=tag, force=True)
self.dockermock.return_value.push(
self.dockermock.return_value.push.assert_called_once_with(
push_image,
tag=tag, stream=True)
def test_upload_image_missing_tag(self):
@mock.patch('subprocess.Popen')
def test_upload_image_missing_tag(self, mock_popen):
image = 'docker.io/tripleoupstream/heat-docker-agents-centos'
expected_tag = 'latest'
push_destination = 'localhost:8787'
@ -142,7 +166,8 @@ class TestDockerImageUploader(base.TestCase):
self.uploader.upload_image(image,
None,
push_destination)
push_destination,
set())
self.dockermock.assert_called_once_with(
base_url='unix://var/run/docker.sock', version='auto')
@ -153,10 +178,36 @@ class TestDockerImageUploader(base.TestCase):
image=image + ':' + expected_tag,
repository=push_image,
tag=expected_tag, force=True)
self.dockermock.return_value.push(
self.dockermock.return_value.push.assert_called_once_with(
push_image,
tag=expected_tag, stream=True)
@mock.patch('subprocess.Popen')
def test_upload_image_existing(self, mock_popen):
result = {
'Digest': 'a'
}
mock_process = mock.Mock()
mock_process.communicate.return_value = (json.dumps(result), '')
mock_process.returncode = 0
mock_popen.return_value = mock_process
image = 'docker.io/tripleoupstream/heat-docker-agents-centos'
tag = 'latest'
push_destination = 'localhost:8787'
self.uploader.upload_image(image + ':' + tag,
None,
push_destination,
set())
self.dockermock.assert_called_once_with(
base_url='unix://var/run/docker.sock', version='auto')
# both digests are the same, no pull/push
self.dockermock.return_value.pull.assert_not_called()
self.dockermock.return_value.tag.assert_not_called()
self.dockermock.return_value.push.assert_not_called()
@mock.patch('requests.get')
def test_is_insecure_registry_known(self, mock_get):
self.assertFalse(
@ -351,3 +402,24 @@ class TestDockerImageUploader(base.TestCase):
dockerc.pull.assert_has_calls([
mock.call(image, tag=None, stream=True)
])
@mock.patch('tripleo_common.image.image_uploader.'
'DockerImageUploader._inspect')
def test_images_match(self, mock_inspect):
mock_inspect.side_effect = [{'Digest': 'a'}, {'Digest': 'b'}]
self.assertFalse(self.uploader._images_match('foo', 'bar', set()))
mock_inspect.side_effect = [{'Digest': 'a'}, {'Digest': 'a'}]
self.assertTrue(self.uploader._images_match('foo', 'bar', set()))
mock_inspect.side_effect = [{}, {'Digest': 'b'}]
self.assertFalse(self.uploader._images_match('foo', 'bar', set()))
mock_inspect.side_effect = [{'Digest': 'a'}, {}]
self.assertFalse(self.uploader._images_match('foo', 'bar', set()))
mock_inspect.side_effect = [None, None]
self.assertFalse(self.uploader._images_match('foo', 'bar', set()))
mock_inspect.side_effect = ImageUploaderException()
self.assertFalse(self.uploader._images_match('foo', 'bar', set()))