docker-storage-gc 3.5.12 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.versionbot/CHANGELOG.yml +19 -1
- package/CHANGELOG.md +6 -0
- package/build/docker-event-stream.d.ts +8 -0
- package/build/docker-event-stream.js +80 -49
- package/build/docker-event-stream.js.map +1 -0
- package/build/docker-image-tree.d.ts +15 -0
- package/build/docker-image-tree.js +57 -76
- package/build/docker-image-tree.js.map +1 -0
- package/build/docker.d.ts +2 -0
- package/build/docker.js +21 -25
- package/build/docker.js.map +1 -0
- package/build/index.d.ts +33 -0
- package/build/index.js +174 -229
- package/build/index.js.map +1 -0
- package/lib/docker-event-stream.ts +93 -0
- package/lib/docker-image-tree.ts +94 -0
- package/lib/docker.ts +17 -0
- package/lib/index.ts +272 -0
- package/package.json +21 -13
- package/test/docker-event-stream.ts +46 -0
- package/test/docker-image-tree.ts +137 -0
- package/test/index.ts +228 -0
- package/tools/graphviz.ts +39 -0
- package/tsconfig.dev.json +13 -0
- package/tsconfig.json +22 -0
- package/index.d.ts +0 -23
- package/lib/docker-event-stream.coffee +0 -46
- package/lib/docker-image-tree.coffee +0 -56
- package/lib/docker.coffee +0 -13
- package/lib/index.coffee +0 -168
- package/test/docker-event-stream.coffee +0 -30
- package/test/docker-image-tree.coffee +0 -124
- package/test/index.coffee +0 -208
- package/tools/graphviz.coffee +0 -24
package/test/index.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type Docker from 'dockerode';
|
|
2
|
+
import Bluebird from 'bluebird';
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import DockerGC from '../build/index';
|
|
5
|
+
import { getDocker } from '../build/docker';
|
|
6
|
+
|
|
7
|
+
const SKIP_GC_TEST = process.env.SKIP_GC_TEST === '1' || false;
|
|
8
|
+
const IMAGES = ['alpine:3.1', 'debian:squeeze', 'ubuntu:lucid'];
|
|
9
|
+
|
|
10
|
+
// TODO: Move it to a proper repo
|
|
11
|
+
// Same image (same id), different repo, different digest
|
|
12
|
+
const NONE_TAG_IMAGES = [
|
|
13
|
+
'hello-world@sha256:8e3114318a995a1ee497790535e7b88365222a21771ae7e53687ad76563e8e76',
|
|
14
|
+
'balenaplayground/hello-world@sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const promiseToBool = (p: Promise<unknown>): Promise<boolean> =>
|
|
18
|
+
p.then(() => true).catch(() => false);
|
|
19
|
+
|
|
20
|
+
const pullAsync = function (docker: Docker, tag: string) {
|
|
21
|
+
console.log(`[TEST] Pulling ${tag}`);
|
|
22
|
+
return docker.pull(tag).then(
|
|
23
|
+
(stream) =>
|
|
24
|
+
new Promise(function (resolve, reject) {
|
|
25
|
+
stream.resume();
|
|
26
|
+
stream.once('error', reject);
|
|
27
|
+
stream.once('end', resolve);
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// This test case is a little weird, it requires that no other images are present on
|
|
33
|
+
// the system to ensure that the correct one is being removed. Because of this, you
|
|
34
|
+
// can use the SKIP_GC_TEST env var to inform the test suite not to run this test
|
|
35
|
+
describe('Garbage collection', function () {
|
|
36
|
+
let dockerStorage: DockerGC;
|
|
37
|
+
let docker: Docker;
|
|
38
|
+
beforeEach(function () {
|
|
39
|
+
dockerStorage = new DockerGC();
|
|
40
|
+
// Use either local or CI docker
|
|
41
|
+
return Bluebird.join(
|
|
42
|
+
getDocker({
|
|
43
|
+
socketPath: '/tmp/dind/docker.sock',
|
|
44
|
+
}),
|
|
45
|
+
dockerStorage.setDocker({
|
|
46
|
+
socketPath: '/tmp/dind/docker.sock',
|
|
47
|
+
}),
|
|
48
|
+
($docker) => {
|
|
49
|
+
docker = $docker;
|
|
50
|
+
return dockerStorage.setupMtimeStream();
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(function () {
|
|
56
|
+
console.log('[afterEach] Cleaning up...');
|
|
57
|
+
return IMAGES.concat(NONE_TAG_IMAGES).map((image) =>
|
|
58
|
+
docker
|
|
59
|
+
.getImage(image)
|
|
60
|
+
.remove()
|
|
61
|
+
.catch(function () {
|
|
62
|
+
// Ignore
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should remove a image by tag', function () {
|
|
68
|
+
this.timeout(600000);
|
|
69
|
+
|
|
70
|
+
return pullAsync(docker, IMAGES[0])
|
|
71
|
+
.then(() => docker.getImage(IMAGES[0]).inspect())
|
|
72
|
+
.then(() => dockerStorage.garbageCollect(1))
|
|
73
|
+
.then(() => promiseToBool(docker.getImage(IMAGES[0]).inspect()))
|
|
74
|
+
.then((imageFound) => expect(imageFound).to.be.false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should remove a image by digest if its tag == none', function () {
|
|
78
|
+
this.timeout(600000);
|
|
79
|
+
|
|
80
|
+
return pullAsync(docker, NONE_TAG_IMAGES[0])
|
|
81
|
+
.then(() => docker.getImage(NONE_TAG_IMAGES[0]).inspect())
|
|
82
|
+
.then(() => dockerStorage.garbageCollect(1))
|
|
83
|
+
.then(() => promiseToBool(docker.getImage(NONE_TAG_IMAGES[0]).inspect()))
|
|
84
|
+
.then((imageFound) => expect(imageFound).to.be.false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should remove a image with tag == none even if it is in several repos', function () {
|
|
88
|
+
this.timeout(600000);
|
|
89
|
+
|
|
90
|
+
return Bluebird.each(NONE_TAG_IMAGES, (image) => pullAsync(docker, image))
|
|
91
|
+
.then(() => docker.getImage(NONE_TAG_IMAGES[0]).inspect())
|
|
92
|
+
.then(() => dockerStorage.garbageCollect(1))
|
|
93
|
+
.then(() =>
|
|
94
|
+
Bluebird.map(NONE_TAG_IMAGES, (image) =>
|
|
95
|
+
promiseToBool(docker.getImage(image).inspect()),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
.then((imagesFound) => expect(imagesFound).to.deep.equal([false, false]));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should remove all tags of an image', function () {
|
|
102
|
+
this.timeout(600000);
|
|
103
|
+
if (SKIP_GC_TEST) {
|
|
104
|
+
return Promise.resolve();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// first pull some images, so we know in which order they are referenced
|
|
108
|
+
return pullAsync(docker, IMAGES[0])
|
|
109
|
+
.then(() =>
|
|
110
|
+
docker.getImage(IMAGES[0]).tag({ repo: 'some-repo', tag: 'some-tag' }),
|
|
111
|
+
)
|
|
112
|
+
.then(() => {
|
|
113
|
+
return dockerStorage.garbageCollect(1);
|
|
114
|
+
})
|
|
115
|
+
.then(() => promiseToBool(docker.getImage(IMAGES[0]).inspect()))
|
|
116
|
+
.then((imagesFound) => expect(imagesFound).to.be.false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should remove the LRU image', function () {
|
|
120
|
+
this.timeout(600000);
|
|
121
|
+
if (SKIP_GC_TEST) {
|
|
122
|
+
return Promise.resolve();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// first pull some images, so we know in which order they are referenced
|
|
126
|
+
return pullAsync(docker, IMAGES[0])
|
|
127
|
+
.then(() =>
|
|
128
|
+
docker.getImage(IMAGES[0]).tag({ repo: 'some-repo', tag: 'some-tag' }),
|
|
129
|
+
)
|
|
130
|
+
.then(() =>
|
|
131
|
+
Bluebird.each(IMAGES.slice(1), (image) => pullAsync(docker, image)),
|
|
132
|
+
)
|
|
133
|
+
.then(() => {
|
|
134
|
+
// Attempt to remove a single byte, which will remove the LRU image,
|
|
135
|
+
// which should be alpine
|
|
136
|
+
return dockerStorage.garbageCollect(1);
|
|
137
|
+
})
|
|
138
|
+
.then(() =>
|
|
139
|
+
Bluebird.map(IMAGES, (image) =>
|
|
140
|
+
promiseToBool(docker.getImage(image).inspect()),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
.then((imagesFound) =>
|
|
144
|
+
expect(imagesFound).to.deep.equal([false, true, true]),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should remove more than one image if necessary', function () {
|
|
149
|
+
this.timeout(600000);
|
|
150
|
+
if (SKIP_GC_TEST) {
|
|
151
|
+
return Promise.resolve();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Bluebird.each(IMAGES, (image) => pullAsync(docker, image))
|
|
155
|
+
.then(() =>
|
|
156
|
+
// Get the size of the first image, so we can add one to it to remove
|
|
157
|
+
// the next one in addition
|
|
158
|
+
docker
|
|
159
|
+
.getImage(IMAGES[0])
|
|
160
|
+
.inspect()
|
|
161
|
+
.then((i) => i.Size),
|
|
162
|
+
)
|
|
163
|
+
.then((size) => {
|
|
164
|
+
return dockerStorage.garbageCollect(size + 1);
|
|
165
|
+
})
|
|
166
|
+
.then(() =>
|
|
167
|
+
Bluebird.map(IMAGES, (image) =>
|
|
168
|
+
promiseToBool(docker.getImage(image).inspect()),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
.then((imagesFound) =>
|
|
172
|
+
expect(imagesFound).to.deep.equal([false, false, true]),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should not consider images in use', function () {
|
|
177
|
+
this.timeout(600000);
|
|
178
|
+
const containerName = 'dont-consider-images-in-use-test';
|
|
179
|
+
if (SKIP_GC_TEST) {
|
|
180
|
+
return Promise.resolve();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return pullAsync(docker, IMAGES[0])
|
|
184
|
+
.then(() =>
|
|
185
|
+
docker.createContainer({
|
|
186
|
+
Image: IMAGES[0],
|
|
187
|
+
Tty: true,
|
|
188
|
+
Cmd: ['sh', '-c', 'while true; do echo test; sleep 1; done'],
|
|
189
|
+
name: containerName,
|
|
190
|
+
HostConfig: { AutoRemove: true },
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
.then((container) => container.start())
|
|
194
|
+
.then(() => {
|
|
195
|
+
return dockerStorage.garbageCollect(1);
|
|
196
|
+
})
|
|
197
|
+
.then(() => promiseToBool(docker.getImage(IMAGES[0]).inspect()))
|
|
198
|
+
.then((imageInspect) => expect(imageInspect).to.be.true)
|
|
199
|
+
.finally(() => docker.getContainer(containerName).stop());
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should get daemon host disk usage', function () {
|
|
203
|
+
this.timeout(600000);
|
|
204
|
+
return dockerStorage.getDaemonFreeSpace().then(function (du) {
|
|
205
|
+
expect(du).to.be.an('object');
|
|
206
|
+
expect(du).to.have.property('free').that.is.a('number');
|
|
207
|
+
expect(du).to.have.property('used').that.is.a('number');
|
|
208
|
+
expect(du).to.have.property('total').that.is.a('number');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should get the correct architecture for a remote host', function () {
|
|
213
|
+
return (
|
|
214
|
+
(
|
|
215
|
+
dockerStorage
|
|
216
|
+
// @ts-expect-error getDaemonArchitecture is private
|
|
217
|
+
.getDaemonArchitecture() as Promise<string>
|
|
218
|
+
).then((arch) => expect(arch).to.be.a('string'))
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should set a base image to be used', function () {
|
|
223
|
+
// @ts-expect-error baseImagePromise is private
|
|
224
|
+
return (dockerStorage.baseImagePromise as Promise<string>).then((img) =>
|
|
225
|
+
expect(img).to.be.a('string'),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ImageNode } from '../lib/docker-image-tree';
|
|
2
|
+
|
|
3
|
+
const id = (tree: ImageNode) => `foo_${tree.id.slice(0, 7)}`;
|
|
4
|
+
|
|
5
|
+
const label = function (tree: ImageNode) {
|
|
6
|
+
const name = tree.repoTags[0] || '\\<none\\>:\\<none\\>';
|
|
7
|
+
const mtime = new Date(tree.mtime!).toISOString();
|
|
8
|
+
|
|
9
|
+
return `label="{ ${tree.id.slice(0, 13)} | ${name} | { ${mtime} | ${
|
|
10
|
+
tree.size
|
|
11
|
+
} } }"`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const color = function (tree: ImageNode) {
|
|
15
|
+
if (tree.repoTags.length === 0) {
|
|
16
|
+
return 'color="black"';
|
|
17
|
+
} else {
|
|
18
|
+
return 'color="red"';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const $createDot = function (tree: ImageNode): string[] {
|
|
23
|
+
// define the node
|
|
24
|
+
return (
|
|
25
|
+
[`${id(tree)} [shape=record, ${label(tree)}, ${color(tree)}]`]
|
|
26
|
+
// define all relations with child nodes
|
|
27
|
+
.concat(
|
|
28
|
+
Object.values(tree.children).map(
|
|
29
|
+
(child) => `${id(tree)} -> ${id(child)}`,
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
// recurse to children
|
|
33
|
+
.concat(...Object.values(tree.children).map((child) => $createDot(child)))
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createDot(tree: ImageNode) {
|
|
38
|
+
return 'digraph {\n' + $createDot(tree).join('\n') + '\n}\n';
|
|
39
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "Node16",
|
|
4
|
+
"strict": true,
|
|
5
|
+
"strictPropertyInitialization": false,
|
|
6
|
+
"noUnusedParameters": true,
|
|
7
|
+
"noUnusedLocals": true,
|
|
8
|
+
"outDir": "build/",
|
|
9
|
+
"preserveConstEnums": true,
|
|
10
|
+
"removeComments": true,
|
|
11
|
+
"rootDir": "lib",
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"target": "es2022",
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"esModuleInterop": true
|
|
18
|
+
},
|
|
19
|
+
"include": [
|
|
20
|
+
"lib/**/*"
|
|
21
|
+
]
|
|
22
|
+
}
|
package/index.d.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type * as Dockerode from 'dockerode';
|
|
2
|
-
import type EventEmitter from 'eventemitter3';
|
|
3
|
-
|
|
4
|
-
interface Events {
|
|
5
|
-
numberImagesToRemove(n: number): void;
|
|
6
|
-
gcRunTime(duration: number): void;
|
|
7
|
-
imageRemoved(removalType: string): void;
|
|
8
|
-
spaceReclaimed(reclaimSpace: number): void;
|
|
9
|
-
imageRemovalError(statusCode: string): void;
|
|
10
|
-
}
|
|
11
|
-
declare class DockerGC {
|
|
12
|
-
setHostname(hostname: string): void;
|
|
13
|
-
setupMtimeStream(): Promise<void>;
|
|
14
|
-
setDocker(dockerOpts: Dockerode.DockerOptions): Promise<void>;
|
|
15
|
-
garbageCollect(reclaimSpace: number, attemptAll?: boolean): Promise<void>;
|
|
16
|
-
getDaemonFreeSpace(): Promise<{
|
|
17
|
-
used: number;
|
|
18
|
-
total: number;
|
|
19
|
-
free: number;
|
|
20
|
-
}>;
|
|
21
|
-
metrics: EventEmitter<Events>;
|
|
22
|
-
}
|
|
23
|
-
export = DockerGC;
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
Bluebird = require 'bluebird'
|
|
2
|
-
es = require 'event-stream'
|
|
3
|
-
JSONStream = require 'JSONStream'
|
|
4
|
-
|
|
5
|
-
IMAGE_EVENTS = [ 'delete', 'import', 'pull', 'push', 'tag' ]
|
|
6
|
-
|
|
7
|
-
CONTAINER_EVENTS = [
|
|
8
|
-
'attach', 'commit', 'copy', 'create', 'destroy', 'die', 'exec_create',
|
|
9
|
-
'exec_start', 'export', 'kill', 'oom', 'pause', 'rename', 'resize',
|
|
10
|
-
'restart', 'start', 'stop', 'top', 'unpause'
|
|
11
|
-
]
|
|
12
|
-
|
|
13
|
-
exports.parseEventStream = parseEventStream = (docker) ->
|
|
14
|
-
docker.listImages(all: true).then((images) ->
|
|
15
|
-
layer_mtimes = {}
|
|
16
|
-
# Start off by setting all current images to an mtime of 0 as we've never seen them used
|
|
17
|
-
# If we've never seen the layer used then it's likely created before we started
|
|
18
|
-
# listening and so set the last used time to 0 as we know it should be older than
|
|
19
|
-
# anything we've seen
|
|
20
|
-
for image in images
|
|
21
|
-
layer_mtimes[image.Id] = 0
|
|
22
|
-
|
|
23
|
-
return es.pipeline(
|
|
24
|
-
JSONStream.parse()
|
|
25
|
-
es.mapSync ({ status, id, from, timeNano }) ->
|
|
26
|
-
if status in IMAGE_EVENTS
|
|
27
|
-
if status == 'delete'
|
|
28
|
-
if layer_mtimes[id]? then delete layer_mtimes[id]
|
|
29
|
-
else
|
|
30
|
-
layer_mtimes[id] = timeNano
|
|
31
|
-
else if status in CONTAINER_EVENTS
|
|
32
|
-
layer_mtimes[from] = timeNano
|
|
33
|
-
return layer_mtimes
|
|
34
|
-
)
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
exports.dockerMtimeStream = (docker) ->
|
|
38
|
-
Bluebird.join(
|
|
39
|
-
docker.getEvents()
|
|
40
|
-
parseEventStream(docker)
|
|
41
|
-
(stream, streamParser) ->
|
|
42
|
-
es.pipeline(
|
|
43
|
-
stream
|
|
44
|
-
streamParser
|
|
45
|
-
)
|
|
46
|
-
)
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
_ = require 'lodash'
|
|
2
|
-
Bluebird = require 'bluebird'
|
|
3
|
-
|
|
4
|
-
saneRepoAttrs = (repoAttrs) ->
|
|
5
|
-
return [] if !repoAttrs?
|
|
6
|
-
return if '<none>:<none>' in repoAttrs or '<none>@<none>' in repoAttrs then [] else repoAttrs
|
|
7
|
-
|
|
8
|
-
exports.createNode = createNode = (id) -> { id: id, size: 0, repoTags: [], repoDigests: [], mtime: null, children: {} }
|
|
9
|
-
|
|
10
|
-
getMtimeFrom = (layer_mtimes, attributes) ->
|
|
11
|
-
for key in attributes
|
|
12
|
-
if layer_mtimes[key]?
|
|
13
|
-
return layer_mtimes[key]
|
|
14
|
-
|
|
15
|
-
getMtime = (tree, layer_mtimes) ->
|
|
16
|
-
mtime = layer_mtimes[tree.id]
|
|
17
|
-
if mtime == undefined
|
|
18
|
-
mtime = getMtimeFrom(layer_mtimes, tree.repoTags)
|
|
19
|
-
if mtime == undefined
|
|
20
|
-
mtime = getMtimeFrom(layer_mtimes, tree.repoDigests)
|
|
21
|
-
return mtime
|
|
22
|
-
|
|
23
|
-
exports.createTree = createTree = (images, containers, layer_mtimes) ->
|
|
24
|
-
now = Date.now() * 10 ** 6 # convert to nanoseconds
|
|
25
|
-
usedImageIds = new Set(
|
|
26
|
-
_(containers)
|
|
27
|
-
.map('ImageID')
|
|
28
|
-
)
|
|
29
|
-
tree = {}
|
|
30
|
-
root = '0000000000000000000000000000000000000000000000000000000000000000'
|
|
31
|
-
|
|
32
|
-
for image in images
|
|
33
|
-
node = tree[image.Id] ?= createNode(image.Id)
|
|
34
|
-
parentId = image.ParentId or root
|
|
35
|
-
parent = tree[parentId] ?= createNode(parentId)
|
|
36
|
-
|
|
37
|
-
node.repoTags = saneRepoAttrs(image.RepoTags)
|
|
38
|
-
node.repoDigests = saneRepoAttrs(image.RepoDigests)
|
|
39
|
-
node.size = image.Size
|
|
40
|
-
# If we haven't seen the image at all then assume it is brand new and default it's
|
|
41
|
-
# mtime to `now` to avoid removing it
|
|
42
|
-
node.mtime = getMtime(node, layer_mtimes) ? now
|
|
43
|
-
node.isUsedByAContainer = usedImageIds.has(image.Id)
|
|
44
|
-
parent.children[image.Id] = node
|
|
45
|
-
|
|
46
|
-
tree[root].mtime = now
|
|
47
|
-
tree[root].isUsedByAContainer = false
|
|
48
|
-
return tree[root]
|
|
49
|
-
|
|
50
|
-
exports.dockerImageTree = (docker, layer_mtimes) ->
|
|
51
|
-
Bluebird.join(
|
|
52
|
-
docker.listImages(all: true)
|
|
53
|
-
docker.listContainers(all: true)
|
|
54
|
-
layer_mtimes
|
|
55
|
-
createTree
|
|
56
|
-
)
|
package/lib/docker.coffee
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
Docker = require 'dockerode'
|
|
2
|
-
Bluebird = require 'bluebird'
|
|
3
|
-
_ = require 'lodash'
|
|
4
|
-
|
|
5
|
-
getDockerConnectOpts = (hostObj) ->
|
|
6
|
-
if !_.isEmpty(hostObj)
|
|
7
|
-
return Bluebird.resolve(hostObj)
|
|
8
|
-
return Bluebird.resolve({ socketPath: '/var/run/docker.sock', Promise: Bluebird })
|
|
9
|
-
|
|
10
|
-
exports.getDocker = (hostObj) ->
|
|
11
|
-
getDockerConnectOpts(hostObj)
|
|
12
|
-
.then (opts) ->
|
|
13
|
-
return new Docker(opts)
|
package/lib/index.coffee
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
Bluebird = require 'bluebird'
|
|
2
|
-
_ = require 'lodash'
|
|
3
|
-
{ EventEmitter } = require 'eventemitter3'
|
|
4
|
-
|
|
5
|
-
{ DockerProgress } = require 'docker-progress'
|
|
6
|
-
Docker = require 'dockerode'
|
|
7
|
-
|
|
8
|
-
{ dockerMtimeStream } = require './docker-event-stream'
|
|
9
|
-
{ dockerImageTree } = require './docker-image-tree'
|
|
10
|
-
dockerUtils = require './docker'
|
|
11
|
-
|
|
12
|
-
getUnusedTreeLeafs = (tree, result = []) ->
|
|
13
|
-
if not tree.removed
|
|
14
|
-
children = _(tree.children)
|
|
15
|
-
.values()
|
|
16
|
-
.filter(_.negate(_.property('removed')))
|
|
17
|
-
.value()
|
|
18
|
-
if children.length == 0 and not tree.isUsedByAContainer
|
|
19
|
-
result.push(tree)
|
|
20
|
-
else
|
|
21
|
-
for child in children
|
|
22
|
-
getUnusedTreeLeafs(child, result)
|
|
23
|
-
return result
|
|
24
|
-
|
|
25
|
-
getImagesToRemove = (tree, reclaimSpace, metrics) ->
|
|
26
|
-
# Removes the oldest, largest leafs first.
|
|
27
|
-
# This should avoid trying to remove images with children.
|
|
28
|
-
tree = _.clone(tree)
|
|
29
|
-
result = []
|
|
30
|
-
size = 0
|
|
31
|
-
while size < reclaimSpace
|
|
32
|
-
leafs = _.orderBy(
|
|
33
|
-
getUnusedTreeLeafs(tree)
|
|
34
|
-
[ 'mtime', 'size' ]
|
|
35
|
-
[ 'asc', 'desc' ]
|
|
36
|
-
)
|
|
37
|
-
if leafs.length == 0
|
|
38
|
-
break
|
|
39
|
-
leaf = leafs[0]
|
|
40
|
-
if leaf != tree
|
|
41
|
-
# don't remove the tree root
|
|
42
|
-
result.push(leaf)
|
|
43
|
-
size += leaf.size
|
|
44
|
-
leaf.removed = true
|
|
45
|
-
|
|
46
|
-
metrics.emit('numberImagesToRemove', result.length)
|
|
47
|
-
return result
|
|
48
|
-
|
|
49
|
-
streamToString = (stream) ->
|
|
50
|
-
new Bluebird (resolve, reject) ->
|
|
51
|
-
chunks = []
|
|
52
|
-
stream
|
|
53
|
-
.on('error', reject)
|
|
54
|
-
.on 'data', (chunk) ->
|
|
55
|
-
chunks.push(chunk)
|
|
56
|
-
.on 'end', ->
|
|
57
|
-
resolve(Buffer.concat(chunks).toString())
|
|
58
|
-
|
|
59
|
-
recordGcRunTime = (t0, metrics) ->
|
|
60
|
-
dt = process.hrtime(t0)
|
|
61
|
-
duration = dt[0] * 1000 + dt[1] / 1e6
|
|
62
|
-
metrics.emit('gcRunTime', duration)
|
|
63
|
-
|
|
64
|
-
class DockerGC
|
|
65
|
-
|
|
66
|
-
constructor: ->
|
|
67
|
-
@metrics = new EventEmitter()
|
|
68
|
-
@host = 'unknown'
|
|
69
|
-
|
|
70
|
-
setHostname: (hostname) ->
|
|
71
|
-
@host = hostname
|
|
72
|
-
|
|
73
|
-
setDocker: (hostObj) ->
|
|
74
|
-
@currentMtimes = {}
|
|
75
|
-
@hostObj = _.defaults({ Promise: Bluebird }, hostObj)
|
|
76
|
-
@dockerProgress = new DockerProgress({ docker: new Docker(@hostObj) })
|
|
77
|
-
dockerUtils.getDocker(@hostObj)
|
|
78
|
-
.then (@docker) =>
|
|
79
|
-
# Docker info can take a while so do it here,
|
|
80
|
-
# and don't wait on the results
|
|
81
|
-
@baseImagePromise = @getDaemonArchitecture()
|
|
82
|
-
.then (arch) ->
|
|
83
|
-
return switch arch
|
|
84
|
-
when 'arm' then 'arm32v6/alpine:3.6'
|
|
85
|
-
when 'arm64' then 'arm64v8/alpine:3.6'
|
|
86
|
-
when 'amd64' then 'alpine:3.6'
|
|
87
|
-
else
|
|
88
|
-
throw new Error('Could not detect architecture of remote host')
|
|
89
|
-
|
|
90
|
-
setupMtimeStream: ->
|
|
91
|
-
dockerMtimeStream(@docker)
|
|
92
|
-
.then (stream) =>
|
|
93
|
-
stream
|
|
94
|
-
.on 'data', (layer_mtimes) =>
|
|
95
|
-
@currentMtimes = layer_mtimes
|
|
96
|
-
|
|
97
|
-
removeImage: (image) =>
|
|
98
|
-
return this.tryRemoveImageBy(image, image.repoTags, 'tag') ||
|
|
99
|
-
this.tryRemoveImageBy(image, image.repoDigests, 'digest') ||
|
|
100
|
-
this.tryRemoveImageBy(image, [image.id], 'id')
|
|
101
|
-
|
|
102
|
-
tryRemoveImageBy: (image, attributes, removalType) =>
|
|
103
|
-
if attributes? and attributes.length > 0
|
|
104
|
-
Bluebird.each attributes, (attribute) =>
|
|
105
|
-
console.log("[GC (#{@host}] Removing image : #{attribute} (id: #{image.id})")
|
|
106
|
-
@docker.getImage(attribute).remove(noprune: true)
|
|
107
|
-
.then =>
|
|
108
|
-
@metrics.emit('imageRemoved', removalType)
|
|
109
|
-
|
|
110
|
-
garbageCollect: (reclaimSpace, attemptAll = false) =>
|
|
111
|
-
err = null
|
|
112
|
-
startTime = process.hrtime()
|
|
113
|
-
@metrics.emit('spaceReclaimed', reclaimSpace)
|
|
114
|
-
dockerImageTree(@docker, @currentMtimes)
|
|
115
|
-
.then (tree) =>
|
|
116
|
-
getImagesToRemove(tree, reclaimSpace, @metrics)
|
|
117
|
-
.each (image) =>
|
|
118
|
-
@removeImage(image)
|
|
119
|
-
.catch (e) =>
|
|
120
|
-
@metrics.emit('imageRemovalError', e.statusCode)
|
|
121
|
-
console.log("[GC #{@host}]: Failed to remove image: ", image)
|
|
122
|
-
console.log(e)
|
|
123
|
-
if attemptAll
|
|
124
|
-
err ?= e
|
|
125
|
-
else
|
|
126
|
-
recordGcRunTime(startTime, @metrics)
|
|
127
|
-
throw e
|
|
128
|
-
.then =>
|
|
129
|
-
recordGcRunTime(startTime, @metrics)
|
|
130
|
-
if err?
|
|
131
|
-
throw err
|
|
132
|
-
|
|
133
|
-
getOutput: (image, command) ->
|
|
134
|
-
Bluebird.using @runDisposer(image, command), (container) ->
|
|
135
|
-
container.logs(stdout: true, follow: true)
|
|
136
|
-
.then (logs) ->
|
|
137
|
-
streamToString(logs)
|
|
138
|
-
|
|
139
|
-
runDisposer: (image, command) ->
|
|
140
|
-
@docker.run(image, command)
|
|
141
|
-
.disposer (container) ->
|
|
142
|
-
container.wait()
|
|
143
|
-
.then ->
|
|
144
|
-
container.remove()
|
|
145
|
-
|
|
146
|
-
getDaemonFreeSpace: ->
|
|
147
|
-
@baseImagePromise.tap (baseImage) =>
|
|
148
|
-
# Ensure the image is available (if it is this is essentially a no-op)
|
|
149
|
-
@dockerProgress.pull(baseImage, _.noop)
|
|
150
|
-
.then (baseImage) =>
|
|
151
|
-
@getOutput(baseImage, [ '/bin/df', '-B', '1', '/' ])
|
|
152
|
-
.then (spaceStr) ->
|
|
153
|
-
# First split the lines, as we're only interested in the second one
|
|
154
|
-
lines = spaceStr.trim().split(/\r?\n/)
|
|
155
|
-
if lines.length isnt 2
|
|
156
|
-
throw new Error('Coult not parse df output')
|
|
157
|
-
|
|
158
|
-
parts = lines[1].split(/\s+/)
|
|
159
|
-
total = parseInt(parts[1])
|
|
160
|
-
used = parseInt(parts[2])
|
|
161
|
-
free = parseInt(parts[3])
|
|
162
|
-
return { used, total, free }
|
|
163
|
-
|
|
164
|
-
getDaemonArchitecture: ->
|
|
165
|
-
@docker.version()
|
|
166
|
-
.get('Arch')
|
|
167
|
-
|
|
168
|
-
module.exports = DockerGC
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{ expect } = require 'chai'
|
|
2
|
-
fs = require 'fs'
|
|
3
|
-
es = require 'event-stream'
|
|
4
|
-
|
|
5
|
-
{ parseEventStream } = require '../lib/docker-event-stream'
|
|
6
|
-
|
|
7
|
-
dockerUtils = require('../lib/docker.coffee')
|
|
8
|
-
|
|
9
|
-
describe 'parseEventStream', ->
|
|
10
|
-
it.skip 'should work with empty stream', ->
|
|
11
|
-
|
|
12
|
-
it 'should return updated mtimes', ->
|
|
13
|
-
dockerUtils.getDocker({})
|
|
14
|
-
.then (docker) ->
|
|
15
|
-
parseEventStream(docker)
|
|
16
|
-
.then (streamParser) ->
|
|
17
|
-
new Promise (resolve, reject) ->
|
|
18
|
-
mtimes = null
|
|
19
|
-
|
|
20
|
-
fs.createReadStream(__dirname + '/fixtures/docker-events.json')
|
|
21
|
-
.pipe(streamParser)
|
|
22
|
-
.on 'error', reject
|
|
23
|
-
.pipe es.mapSync (data) ->
|
|
24
|
-
mtimes = data
|
|
25
|
-
.on 'end', -> resolve(mtimes)
|
|
26
|
-
.on 'error', reject
|
|
27
|
-
.then (data) ->
|
|
28
|
-
expect(data).to.have.property('busybox:latest').that.equals(1448576072937294800)
|
|
29
|
-
expect(data).to.have.property('sha256:6d41a4a0bf8168363e29da8a5ecbf3cd6c37e3f5a043decd5e7da6e427ba869c').that.equals(1448576073085559800)
|
|
30
|
-
expect(data).to.have.property('sha256:9a61b6b1315e6b457c31a03346ab94486a2f5397f4a82219bee01eead1c34c2e').that.equals(1448576073203895800)
|