Initial commit.
This commit is contained in:
commit
1d0c4acc6c
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/datashake
|
||||
/release
|
||||
datashake.json
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"datashake"
|
||||
]
|
||||
}
|
13
Makefile
Normal file
13
Makefile
Normal file
@ -0,0 +1,13 @@
|
||||
define build
|
||||
GOOS=$(1) GOARCH=$(2) \
|
||||
go build \
|
||||
-o release/$(1)-$(2)/datashake
|
||||
endef
|
||||
|
||||
.PHONY: all
|
||||
all:
|
||||
$(call build,linux,amd64)
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
$(RM) -r release
|
66
README.md
Normal file
66
README.md
Normal file
@ -0,0 +1,66 @@
|
||||
# datashake - level out zpools by rewriting files
|
||||
|
||||
## Background
|
||||
|
||||
I have a niche problem: my storage server's ZFS pool is lumpy!
|
||||
|
||||
```
|
||||
NAME SIZE ALLOC FREE FRAG CAP HEALTH
|
||||
zones 32.6T 12.2T 20.4T 3% 37% ONLINE
|
||||
mirror 3.62T 2.21T 1.41T 5% 61.1% ONLINE
|
||||
c0t5000CCA25DE8EBF4d0 - - - - - ONLINE
|
||||
c0t5000CCA25DEEC08Ad0 - - - - - ONLINE
|
||||
mirror 3.62T 2.22T 1.40T 6% 61.3% ONLINE
|
||||
c0t5000CCA25DE6FD92d0 - - - - - ONLINE
|
||||
c0t5000CCA25DEEC738d0 - - - - - ONLINE
|
||||
mirror 3.62T 2.28T 1.34T 6% 63.0% ONLINE
|
||||
c0t5000CCA25DEAA3EEd0 - - - - - ONLINE
|
||||
c0t5000CCA25DE6F42Ed0 - - - - - ONLINE
|
||||
mirror 3.62T 2.29T 1.33T 5% 63.2% ONLINE
|
||||
c0t5000CCA25DE9DB9Dd0 - - - - - ONLINE
|
||||
c0t5000CCA25DEED5B7d0 - - - - - ONLINE
|
||||
mirror 3.62T 2.29T 1.34T 5% 63.1% ONLINE
|
||||
c0t5000CCA25DEB0F42d0 - - - - - ONLINE
|
||||
c0t5000CCA25DEECB9Dd0 - - - - - ONLINE
|
||||
mirror 3.62T 237G 3.39T 1% 6.38% ONLINE
|
||||
c0t5000CCA24CF36876d0 - - - - - ONLINE
|
||||
c0t5000CCA249D4AA59d0 - - - - - ONLINE
|
||||
mirror 3.62T 236G 3.39T 0% 6.36% ONLINE
|
||||
c0t5000CCA24CE9D1CAd0 - - - - - ONLINE
|
||||
c0t5000CCA24CE954D2d0 - - - - - ONLINE
|
||||
mirror 3.62T 228G 3.40T 0% 6.13% ONLINE
|
||||
c0t5000CCA24CE8C60Ed0 - - - - - ONLINE
|
||||
c0t5000CCA24CE9D249d0 - - - - - ONLINE
|
||||
mirror 3.62T 220G 3.41T 0% 5.93% ONLINE
|
||||
c0t5000CCA24CF80849d0 - - - - - ONLINE
|
||||
c0t5000CCA24CF80838d0 - - - - - ONLINE
|
||||
```
|
||||
|
||||
You can probably guess what happened: I had a zpool with five mirrors, and then
|
||||
expanded it by adding four more mirrors. ZFS doesn't automatically rebalance
|
||||
existing data, but does skew writes of new data so that more go to the newer
|
||||
mirrors.
|
||||
|
||||
To rebalance the data manually, the algorithm is straightforward:
|
||||
|
||||
for file in dataset,
|
||||
* copy the file to a temporary directory in another dataset
|
||||
* delete the original file
|
||||
* copy from the temporary directory to recreate the original file
|
||||
* delete the temporary directory
|
||||
|
||||
As the files get rewritten, not only do the newer mirrors get more full, but
|
||||
also the older mirrors free up space. Eventually, the utilization of all mirrors
|
||||
should converge.
|
||||
|
||||
## Solution
|
||||
|
||||
The `datashake` program aims to automate the rebalancing process, while also
|
||||
adding some robustness and heuristics.
|
||||
|
||||
* Gracefully handle shutdowns (e.g. Ctrl-c) to prevent files from getting lost.
|
||||
* Keep track of processed files, so that if the program stops and resumes, it
|
||||
can skip those files.
|
||||
* Write a journal of operations so that, if shut down ungracefully, files in
|
||||
the temporary directory can be identified and recovered.
|
||||
* Don't bother processing really small files.
|
228
datashake.go
Normal file
228
datashake.go
Normal file
@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if err := loadDb(); err != nil {
|
||||
fmt.Println("error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
running.Store(true)
|
||||
go func() {
|
||||
if err := filepath.WalkDir(*sourceDir, process); err != nil {
|
||||
errors <- err
|
||||
}
|
||||
close(errors)
|
||||
}()
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case err, ok := <-errors:
|
||||
if ok {
|
||||
fmt.Println("error:", err)
|
||||
} else {
|
||||
break Loop
|
||||
}
|
||||
case <-signals:
|
||||
running.Store(false)
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveDb(); err != nil {
|
||||
fmt.Println("error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var sourceDir = flag.String("source", "", "source data directory")
|
||||
var tempDir = flag.String("temp", "", "temporary storage directory")
|
||||
var dbPath = flag.String("db", "datashake.json", "database file path")
|
||||
var minimumSize = flag.Int64("min-size", 1024*1024, "minimum size in bytes")
|
||||
|
||||
var errors = make(chan error)
|
||||
var db = DB{
|
||||
Processed: make(map[string]struct{}),
|
||||
}
|
||||
var running atomic.Bool
|
||||
|
||||
func loadDb() error {
|
||||
if *dbPath == "" {
|
||||
return nil
|
||||
}
|
||||
dbFile, err := os.Open(*dbPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
_ = dbFile.Close()
|
||||
}()
|
||||
d := json.NewDecoder(dbFile)
|
||||
err = d.Decode(&db)
|
||||
return err
|
||||
}
|
||||
|
||||
func saveDb() error {
|
||||
if *dbPath == "" {
|
||||
return nil
|
||||
}
|
||||
dbFile, err := os.Create(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = dbFile.Close()
|
||||
}()
|
||||
e := json.NewEncoder(dbFile)
|
||||
err = e.Encode(&db)
|
||||
return err
|
||||
}
|
||||
|
||||
// process is a visitor for `filepath.WalkDir` that implements the rebalancing
|
||||
// algorithm.
|
||||
//
|
||||
// This function never returns an error, since that would stop the directory
|
||||
// directory walk. Instead, any errors are sent to the `errors` channel.
|
||||
func process(path string, d fs.DirEntry, err error) (alwaysNil error) {
|
||||
if !running.Load() {
|
||||
return fs.SkipAll
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
errors <- err
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil || d.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
srcFileName := d.Name()
|
||||
srcFilePath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if db.Contains(srcFilePath) {
|
||||
return
|
||||
}
|
||||
srcStat, err := os.Stat(srcFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if srcStat.Size() < *minimumSize {
|
||||
return
|
||||
}
|
||||
|
||||
tempDirPath, err := os.MkdirTemp(*tempDir, "*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tempFilePath := filepath.Join(tempDirPath, srcFileName)
|
||||
safeToRemoveTemp := true
|
||||
defer func() {
|
||||
if !safeToRemoveTemp {
|
||||
err := fmt.Errorf(
|
||||
"%s may be lost in %s",
|
||||
srcFilePath, tempDirPath,
|
||||
)
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(tempDirPath); err != nil {
|
||||
errors <- err
|
||||
}
|
||||
}()
|
||||
|
||||
err = copy(srcFilePath, tempFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
safeToRemoveTemp = false
|
||||
err = os.Remove(srcFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = copy(tempFilePath, srcFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
safeToRemoveTemp = true
|
||||
db.Remember(srcFilePath)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// copy opens the file from the source path, then creates a copy of it at the
|
||||
// destination path. The mode, uid and gid bits from the source file are
|
||||
// replicated in the copy.
|
||||
func copy(srcPath, dstPath string) error {
|
||||
fmt.Println("copying", srcPath, "to", dstPath)
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = srcFile.Close()
|
||||
}()
|
||||
|
||||
dstFile, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = dstFile.Close()
|
||||
}()
|
||||
|
||||
srcStat, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.Chmod(dstPath, srcStat.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sysStat, ok := srcStat.Sys().(*syscall.Stat_t); ok {
|
||||
uid := int(sysStat.Uid)
|
||||
gid := int(sysStat.Gid)
|
||||
err = os.Chown(dstPath, uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
type DB struct {
|
||||
Processed map[string]struct{}
|
||||
}
|
||||
|
||||
func (db *DB) Contains(path string) bool {
|
||||
_, ok := db.Processed[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (db *DB) Remember(path string) {
|
||||
db.Processed[path] = struct{}{}
|
||||
}
|
Loading…
Reference in New Issue
Block a user