Skip to content

Commit 4ff54f2

Browse files
author
abushwang
committed
Add optional TTL-based cleanup for httpcache to reduce disk and memory usage
Signed-off-by: abushwang <abushwang@tencent.com>
1 parent 3985b1a commit 4ff54f2

File tree

4 files changed

+251
-0
lines changed

4 files changed

+251
-0
lines changed

cache/httpcache_ttl.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cache
18+
19+
import (
20+
"io/fs"
21+
"os"
22+
"path/filepath"
23+
"sync"
24+
"time"
25+
26+
"github.com/containerd/log"
27+
)
28+
29+
var (
30+
cleanupJanitorMu sync.Mutex
31+
globalCleanupJanitor *cleanupJanitor
32+
)
33+
34+
type cleanupJanitor struct {
35+
rootDir string
36+
ttl time.Duration
37+
38+
stopCh chan struct{}
39+
wg sync.WaitGroup
40+
}
41+
42+
// StartCleanupJanitor starts the process-wide janitor for httpcache cleanup.
43+
func StartCleanupJanitor(rootDir string, ttl time.Duration) {
44+
if ttl <= 0 {
45+
return
46+
}
47+
48+
cleanupJanitorMu.Lock()
49+
defer cleanupJanitorMu.Unlock()
50+
51+
if globalCleanupJanitor != nil {
52+
if globalCleanupJanitor.rootDir != rootDir || globalCleanupJanitor.ttl != ttl {
53+
log.L.WithFields(map[string]any{
54+
"root": rootDir,
55+
"configured": ttl,
56+
"existing_root": globalCleanupJanitor.rootDir,
57+
"existing_ttl": globalCleanupJanitor.ttl,
58+
}).Warn("httpcache janitor already initialized; reusing existing janitor")
59+
}
60+
return
61+
}
62+
63+
janitor := &cleanupJanitor{
64+
rootDir: rootDir,
65+
ttl: ttl,
66+
stopCh: make(chan struct{}),
67+
}
68+
janitor.start()
69+
globalCleanupJanitor = janitor
70+
}
71+
72+
func (j *cleanupJanitor) start() {
73+
j.wg.Add(1)
74+
go func() {
75+
defer j.wg.Done()
76+
77+
ticker := time.NewTicker(j.ttl)
78+
defer ticker.Stop()
79+
80+
j.cleanupOnce()
81+
for {
82+
select {
83+
case <-ticker.C:
84+
j.cleanupOnce()
85+
case <-j.stopCh:
86+
return
87+
}
88+
}
89+
}()
90+
}
91+
92+
func (j *cleanupJanitor) cleanupOnce() {
93+
if j.ttl <= 0 {
94+
return
95+
}
96+
97+
cutoff := time.Now().Add(-j.ttl)
98+
_ = filepath.WalkDir(j.rootDir, func(path string, d fs.DirEntry, walkErr error) error {
99+
if walkErr != nil {
100+
return nil
101+
}
102+
select {
103+
case <-j.stopCh:
104+
return fs.SkipAll
105+
default:
106+
}
107+
if d.IsDir() {
108+
if d.Name() == "wip" {
109+
return fs.SkipDir
110+
}
111+
return nil
112+
}
113+
114+
info, err := d.Info()
115+
if err != nil || info.ModTime().After(cutoff) {
116+
return nil
117+
}
118+
119+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
120+
log.L.WithError(err).Debugf("failed to remove expired cache entry %q", path)
121+
}
122+
return nil
123+
})
124+
}

cache/httpcache_ttl_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cache
18+
19+
import (
20+
"os"
21+
"path/filepath"
22+
"testing"
23+
"time"
24+
)
25+
26+
func TestStartCleanupJanitorInitializesOnce(t *testing.T) {
27+
stopCleanupJanitorForTest()
28+
t.Cleanup(stopCleanupJanitorForTest)
29+
30+
root := t.TempDir()
31+
StartCleanupJanitor(root, time.Second)
32+
33+
cleanupJanitorMu.Lock()
34+
first := globalCleanupJanitor
35+
cleanupJanitorMu.Unlock()
36+
if first == nil {
37+
t.Fatalf("expected janitor to be initialized")
38+
}
39+
40+
otherRoot := t.TempDir()
41+
StartCleanupJanitor(otherRoot, 2*time.Second)
42+
43+
cleanupJanitorMu.Lock()
44+
second := globalCleanupJanitor
45+
cleanupJanitorMu.Unlock()
46+
if second != first {
47+
t.Fatalf("expected existing janitor to be reused")
48+
}
49+
}
50+
51+
func TestCleanupJanitorCleanupOnceRemovesExpiredFilesAcrossRoot(t *testing.T) {
52+
root := t.TempDir()
53+
digestDir := filepath.Join(root, "sha256-a")
54+
wipDir := filepath.Join(digestDir, "wip")
55+
if err := os.MkdirAll(wipDir, 0700); err != nil {
56+
t.Fatalf("mkdir wip failed: %v", err)
57+
}
58+
59+
expired := filepath.Join(digestDir, "aa", "expired")
60+
if err := os.MkdirAll(filepath.Dir(expired), 0700); err != nil {
61+
t.Fatalf("mkdir expired dir failed: %v", err)
62+
}
63+
if err := os.WriteFile(expired, []byte("x"), 0600); err != nil {
64+
t.Fatalf("write expired failed: %v", err)
65+
}
66+
67+
expiredWip := filepath.Join(wipDir, "tmp-expired")
68+
if err := os.WriteFile(expiredWip, []byte("y"), 0600); err != nil {
69+
t.Fatalf("write expired wip failed: %v", err)
70+
}
71+
72+
fresh := filepath.Join(root, "sha256-b", "bb", "fresh")
73+
if err := os.MkdirAll(filepath.Dir(fresh), 0700); err != nil {
74+
t.Fatalf("mkdir fresh dir failed: %v", err)
75+
}
76+
if err := os.WriteFile(fresh, []byte("z"), 0600); err != nil {
77+
t.Fatalf("write fresh failed: %v", err)
78+
}
79+
80+
old := time.Now().Add(-2 * time.Second)
81+
if err := os.Chtimes(expired, old, old); err != nil {
82+
t.Fatalf("chtimes expired failed: %v", err)
83+
}
84+
if err := os.Chtimes(expiredWip, old, old); err != nil {
85+
t.Fatalf("chtimes expired wip failed: %v", err)
86+
}
87+
88+
janitor := &cleanupJanitor{
89+
rootDir: root,
90+
ttl: 100 * time.Millisecond,
91+
stopCh: make(chan struct{}),
92+
}
93+
janitor.cleanupOnce()
94+
95+
if _, err := os.Stat(expired); !os.IsNotExist(err) {
96+
t.Fatalf("expected expired file to be removed; err=%v", err)
97+
}
98+
if _, err := os.Stat(expiredWip); err != nil {
99+
t.Fatalf("expected wip file to remain; err=%v", err)
100+
}
101+
if _, err := os.Stat(fresh); err != nil {
102+
t.Fatalf("expected fresh file to remain; err=%v", err)
103+
}
104+
}
105+
106+
func stopCleanupJanitorForTest() {
107+
cleanupJanitorMu.Lock()
108+
janitor := globalCleanupJanitor
109+
globalCleanupJanitor = nil
110+
cleanupJanitorMu.Unlock()
111+
112+
if janitor != nil {
113+
close(janitor.stopCh)
114+
janitor.wg.Wait()
115+
}
116+
}

fs/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ type Config struct {
3939
// Other values default to cache them on disk.
4040
HTTPCacheType string `toml:"http_cache_type" json:"http_cache_type"`
4141

42+
// HTTPCacheChunkTTLSec specifies TTL (in sec) for each http cache chunk.
43+
// Zero or negative values disable TTL-based cleanup.
44+
HTTPCacheChunkTTLSec int `toml:"http_cache_chunk_ttl_sec" json:"http_cache_chunk_ttl_sec"`
45+
4246
// Type of cache for uncompressed files contents. "memory" stores them on memory. Other values
4347
// default to cache them on disk.
4448
FSCacheType string `toml:"filesystem_cache_type" json:"filesystem_cache_type"`

fs/layer/layer.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ func NewResolver(root string, backgroundTaskManager *task.BackgroundTaskManager,
181181
return nil, err
182182
}
183183

184+
if cfg.HTTPCacheType != memoryCacheType && cfg.HTTPCacheChunkTTLSec > 0 {
185+
cache.StartCleanupJanitor(
186+
filepath.Join(root, "httpcache"),
187+
time.Duration(cfg.HTTPCacheChunkTTLSec)*time.Second,
188+
)
189+
}
190+
184191
return &Resolver{
185192
rootDir: root,
186193
resolver: remote.NewResolver(cfg.BlobConfig, resolveHandlers),

0 commit comments

Comments
 (0)