zotregistry.io/zot@v1.4.4-0.20231124084042-02a8ed785457/cmd/zb/perf.go (about) 1 package main 2 3 import ( 4 crand "crypto/rand" 5 "crypto/tls" 6 "fmt" 7 "log" 8 "math/big" 9 "net" 10 "net/http" 11 urlparser "net/url" 12 "os" 13 "path" 14 "sort" 15 "strings" 16 "sync" 17 "text/tabwriter" 18 "time" 19 20 jsoniter "github.com/json-iterator/go" 21 godigest "github.com/opencontainers/go-digest" 22 "gopkg.in/resty.v1" 23 24 "zotregistry.io/zot/pkg/api/constants" 25 ) 26 27 const ( 28 KiB = 1 * 1024 29 MiB = 1 * KiB * 1024 30 GiB = 1 * MiB * 1024 31 maxSize = 1 * GiB // 1GiB 32 defaultDirPerms = 0o700 33 defaultFilePerms = 0o600 34 defaultSchemaVersion = 2 35 smallBlob = 1 * MiB 36 mediumBlob = 10 * MiB 37 largeBlob = 100 * MiB 38 cicdFmt = "ci-cd" 39 secureProtocol = "https" 40 httpKeepAlive = 30 * time.Second 41 maxSourceIPs = 1000 42 httpTimeout = 30 * time.Second 43 TLSHandshakeTimeout = 10 * time.Second 44 ) 45 46 //nolint:gochecknoglobals 47 var blobHash map[string]godigest.Digest = map[string]godigest.Digest{} 48 49 //nolint:gochecknoglobals // used only in this test 50 var statusRequests sync.Map 51 52 func setup(workingDir string) { 53 _ = os.MkdirAll(workingDir, defaultDirPerms) 54 55 const multiplier = 10 56 57 const rndPageSize = 4 * KiB 58 59 for size := 1 * MiB; size < maxSize; size *= multiplier { 60 fname := path.Join(workingDir, fmt.Sprintf("%d.blob", size)) 61 62 fhandle, err := os.OpenFile(fname, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultFilePerms) 63 if err != nil { 64 log.Fatal(err) 65 } 66 67 err = fhandle.Truncate(int64(size)) 68 if err != nil { 69 log.Fatal(err) 70 } 71 72 _, err = fhandle.Seek(0, 0) 73 if err != nil { 74 log.Fatal(err) 75 } 76 77 // write a random first page so every test run has different blob content 78 rnd := make([]byte, rndPageSize) 79 if _, err := crand.Read(rnd); err != nil { 80 log.Fatal(err) 81 } 82 83 if _, err := fhandle.Write(rnd); err != nil { 84 log.Fatal(err) 85 } 86 87 if _, err := fhandle.Seek(0, 0); err != nil { 88 log.Fatal(err) 89 } 90 91 fhandle.Close() // should flush the write 92 93 // pre-compute the SHA256 94 fhandle, err = os.OpenFile(fname, os.O_RDONLY, defaultFilePerms) 95 if err != nil { 96 log.Fatal(err) 97 } 98 99 defer fhandle.Close() 100 101 digest, err := godigest.FromReader(fhandle) 102 if err != nil { 103 log.Fatal(err) //nolint:gocritic // file closed on exit 104 } 105 106 blobHash[fname] = digest 107 } 108 } 109 110 func teardown(workingDir string) { 111 _ = os.RemoveAll(workingDir) 112 } 113 114 // statistics handling. 115 116 type Durations []time.Duration 117 118 func (a Durations) Len() int { return len(a) } 119 func (a Durations) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 120 func (a Durations) Less(i, j int) bool { return a[i] < a[j] } 121 122 type statsSummary struct { 123 latencies []time.Duration 124 name string 125 min, max, total time.Duration 126 statusHist map[string]int 127 rps float32 128 mixedSize, mixedType bool 129 errors int 130 } 131 132 func newStatsSummary(name string) statsSummary { 133 summary := statsSummary{ 134 name: name, 135 min: -1, 136 max: -1, 137 statusHist: make(map[string]int), 138 mixedSize: false, 139 mixedType: false, 140 } 141 142 return summary 143 } 144 145 type statsRecord struct { 146 latency time.Duration 147 statusCode int 148 isConnFail bool 149 isErr bool 150 } 151 152 func updateStats(summary *statsSummary, record statsRecord) { 153 if record.isConnFail || record.isErr { 154 summary.errors++ 155 } 156 157 if summary.min < 0 || record.latency < summary.min { 158 summary.min = record.latency 159 } 160 161 if summary.max < 0 || record.latency > summary.max { 162 summary.max = record.latency 163 } 164 165 // 2xx 166 if record.statusCode >= http.StatusOK && 167 record.statusCode <= http.StatusAccepted { 168 summary.statusHist["2xx"]++ 169 } 170 171 // 3xx 172 if record.statusCode >= http.StatusMultipleChoices && 173 record.statusCode <= http.StatusPermanentRedirect { 174 summary.statusHist["3xx"]++ 175 } 176 177 // 4xx 178 if record.statusCode >= http.StatusBadRequest && 179 record.statusCode <= http.StatusUnavailableForLegalReasons { 180 summary.statusHist["4xx"]++ 181 } 182 183 // 5xx 184 if record.statusCode >= http.StatusInternalServerError && 185 record.statusCode <= http.StatusNetworkAuthenticationRequired { 186 summary.statusHist["5xx"]++ 187 } 188 189 summary.latencies = append(summary.latencies, record.latency) 190 } 191 192 type cicdTestSummary struct { 193 Name string `json:"name"` 194 Unit string `json:"unit"` 195 Value interface{} `json:"value"` 196 Range string `json:"range,omitempty"` 197 } 198 199 type manifestStruct struct { 200 manifestHash map[string]string 201 manifestBySizeHash map[int](map[string]string) 202 } 203 204 //nolint:gochecknoglobals // used only in this test 205 var cicdSummary = []cicdTestSummary{} 206 207 func printStats(requests int, summary *statsSummary, outFmt string) { 208 log.Printf("============\n") 209 log.Printf("Test name:\t%s", summary.name) 210 log.Printf("Time taken for tests:\t%v", summary.total) 211 log.Printf("Complete requests:\t%v", requests-summary.errors) 212 log.Printf("Failed requests:\t%v", summary.errors) 213 log.Printf("Requests per second:\t%v", summary.rps) 214 log.Printf("\n") 215 216 if summary.mixedSize { 217 current := loadOrStore(&statusRequests, "1MB", 0) 218 log.Printf("1MB:\t%v", current) 219 220 current = loadOrStore(&statusRequests, "10MB", 0) 221 log.Printf("10MB:\t%v", current) 222 223 current = loadOrStore(&statusRequests, "100MB", 0) 224 log.Printf("100MB:\t%v", current) 225 226 log.Printf("\n") 227 } 228 229 if summary.mixedType { 230 pull := loadOrStore(&statusRequests, "Pull", 0) 231 log.Printf("Pull:\t%v", pull) 232 233 push := loadOrStore(&statusRequests, "Push", 0) 234 log.Printf("Push:\t%v", push) 235 236 log.Printf("\n") 237 } 238 239 for k, v := range summary.statusHist { 240 log.Printf("%s responses:\t%v", k, v) 241 } 242 243 log.Printf("\n") 244 log.Printf("min: %v", summary.min) 245 log.Printf("max: %v", summary.max) 246 log.Printf("%s:\t%v", "p50", summary.latencies[requests/2]) 247 log.Printf("%s:\t%v", "p75", summary.latencies[requests*3/4]) 248 log.Printf("%s:\t%v", "p90", summary.latencies[requests*9/10]) 249 log.Printf("%s:\t%v", "p99", summary.latencies[requests*99/100]) 250 log.Printf("\n") 251 252 // ci/cd 253 if outFmt == cicdFmt { 254 cicdSummary = append(cicdSummary, 255 cicdTestSummary{ 256 Name: summary.name, 257 Unit: "requests per sec", 258 Value: summary.rps, 259 Range: "3", 260 }, 261 ) 262 } 263 } 264 265 // test suites/funcs. 266 267 type testFunc func( 268 workdir, url, repo string, 269 requests int, 270 config testConfig, 271 statsCh chan statsRecord, 272 client *resty.Client, 273 skipCleanup bool, 274 ) error 275 276 //nolint:gosec 277 func GetCatalog( 278 workdir, url, repo string, 279 requests int, 280 config testConfig, 281 statsCh chan statsRecord, 282 client *resty.Client, 283 skipCleanup bool, 284 ) error { 285 var repos []string 286 287 var err error 288 289 statusRequests = sync.Map{} 290 291 for count := 0; count < requests; count++ { 292 // Push random blob 293 _, repos, err = pushMonolithImage(workdir, url, repo, repos, config, client) 294 if err != nil { 295 return err 296 } 297 } 298 299 for count := 0; count < requests; count++ { 300 func() { 301 start := time.Now() 302 303 var isConnFail, isErr bool 304 305 var statusCode int 306 307 var latency time.Duration 308 309 defer func() { 310 // send a stats record 311 statsCh <- statsRecord{ 312 latency: latency, 313 statusCode: statusCode, 314 isConnFail: isConnFail, 315 isErr: isErr, 316 } 317 }() 318 319 // send request and get response 320 resp, err := client.R().Get(url + constants.RoutePrefix + constants.ExtCatalogPrefix) 321 322 latency = time.Since(start) 323 324 if err != nil { 325 isConnFail = true 326 327 return 328 } 329 330 // request specific check 331 statusCode = resp.StatusCode() 332 if statusCode != http.StatusOK { 333 isErr = true 334 335 return 336 } 337 }() 338 } 339 340 // clean up 341 if !skipCleanup { 342 err = deleteTestRepo(repos, url, client) 343 if err != nil { 344 return err 345 } 346 } 347 348 return nil 349 } 350 351 func PushMonolithStreamed( 352 workdir, url, trepo string, 353 requests int, 354 config testConfig, 355 statsCh chan statsRecord, 356 client *resty.Client, 357 skipCleanup bool, 358 ) error { 359 var repos []string 360 361 if config.mixedSize { 362 statusRequests = sync.Map{} 363 } 364 365 for count := 0; count < requests; count++ { 366 repos = pushMonolithAndCollect(workdir, url, trepo, count, 367 repos, config, client, statsCh) 368 } 369 370 // clean up 371 if !skipCleanup { 372 err := deleteTestRepo(repos, url, client) 373 if err != nil { 374 return err 375 } 376 } 377 378 return nil 379 } 380 381 func PushChunkStreamed( 382 workdir, url, trepo string, 383 requests int, 384 config testConfig, 385 statsCh chan statsRecord, 386 client *resty.Client, 387 skipCleanup bool, 388 ) error { 389 var repos []string 390 391 if config.mixedSize { 392 statusRequests = sync.Map{} 393 } 394 395 for count := 0; count < requests; count++ { 396 repos = pushChunkAndCollect(workdir, url, trepo, count, 397 repos, config, client, statsCh) 398 } 399 400 // clean up 401 if !skipCleanup { 402 err := deleteTestRepo(repos, url, client) 403 if err != nil { 404 return err 405 } 406 } 407 408 return nil 409 } 410 411 func Pull( 412 workdir, url, trepo string, 413 requests int, 414 config testConfig, 415 statsCh chan statsRecord, 416 client *resty.Client, 417 skipCleanup bool, 418 ) error { 419 var repos []string 420 421 var manifestHash map[string]string 422 423 manifestBySizeHash := make(map[int](map[string]string)) 424 425 if config.mixedSize { 426 statusRequests = sync.Map{} 427 } 428 429 if config.mixedSize { 430 var manifestBySize map[string]string 431 432 smallSizeIdx := 0 433 mediumSizeIdx := 1 434 largeSizeIdx := 2 435 436 config.size = smallBlob 437 438 // Push small blob 439 manifestBySize, repos, err := pushMonolithImage(workdir, url, trepo, repos, config, client) 440 if err != nil { 441 return err 442 } 443 444 manifestBySizeHash[smallSizeIdx] = manifestBySize 445 446 config.size = mediumBlob 447 448 // Push medium blob 449 manifestBySize, repos, err = pushMonolithImage(workdir, url, trepo, repos, config, client) 450 if err != nil { 451 return err 452 } 453 454 manifestBySizeHash[mediumSizeIdx] = manifestBySize 455 456 config.size = largeBlob 457 458 // Push large blob 459 //nolint: ineffassign, staticcheck, wastedassign 460 manifestBySize, repos, err = pushMonolithImage(workdir, url, trepo, repos, config, client) 461 if err != nil { 462 return err 463 } 464 465 manifestBySizeHash[largeSizeIdx] = manifestBySize 466 } else { 467 // Push blob given size 468 var err error 469 manifestHash, repos, err = pushMonolithImage(workdir, url, trepo, repos, config, client) 470 if err != nil { 471 return err 472 } 473 } 474 475 manifestItem := manifestStruct{ 476 manifestHash: manifestHash, 477 manifestBySizeHash: manifestBySizeHash, 478 } 479 480 // download image 481 for count := 0; count < requests; count++ { 482 repos = pullAndCollect(url, repos, manifestItem, config, client, statsCh) 483 } 484 485 // clean up 486 if !skipCleanup { 487 err := deleteTestRepo(repos, url, client) 488 if err != nil { 489 return err 490 } 491 } 492 493 return nil 494 } 495 496 func MixedPullAndPush( 497 workdir, url, trepo string, 498 requests int, 499 config testConfig, 500 statsCh chan statsRecord, 501 client *resty.Client, 502 skipCleanup bool, 503 ) error { 504 var repos []string 505 506 statusRequests = sync.Map{} 507 508 // Push blob given size 509 manifestHash, repos, err := pushMonolithImage(workdir, url, trepo, repos, config, client) 510 if err != nil { 511 return err 512 } 513 514 manifestItem := manifestStruct{ 515 manifestHash: manifestHash, 516 } 517 518 for count := 0; count < requests; count++ { 519 idx := flipFunc(config.probabilityRange) 520 521 readTestIdx := 0 522 writeTestIdx := 1 523 524 if idx == readTestIdx { 525 repos = pullAndCollect(url, repos, manifestItem, config, client, statsCh) 526 current := loadOrStore(&statusRequests, "Pull", 0) 527 statusRequests.Store("Pull", current+1) 528 } else if idx == writeTestIdx { 529 repos = pushMonolithAndCollect(workdir, url, trepo, count, repos, config, client, statsCh) 530 current := loadOrStore(&statusRequests, "Push", 0) 531 statusRequests.Store("Pull", current+1) 532 } 533 } 534 535 // clean up 536 if !skipCleanup { 537 err = deleteTestRepo(repos, url, client) 538 if err != nil { 539 return err 540 } 541 } 542 543 return nil 544 } 545 546 // test driver. 547 548 type testConfig struct { 549 name string 550 tfunc testFunc 551 // test-specific params 552 size int 553 probabilityRange []float64 554 mixedSize, mixedType bool 555 } 556 557 var testSuite = []testConfig{ //nolint:gochecknoglobals // used only in this test 558 { 559 name: "Get Catalog", 560 tfunc: GetCatalog, 561 probabilityRange: normalizeProbabilityRange([]float64{0.7, 0.2, 0.1}), 562 }, 563 { 564 name: "Push Monolith 1MB", 565 tfunc: PushMonolithStreamed, 566 size: smallBlob, 567 }, 568 { 569 name: "Push Monolith 10MB", 570 tfunc: PushMonolithStreamed, 571 size: mediumBlob, 572 }, 573 { 574 name: "Push Monolith 100MB", 575 tfunc: PushMonolithStreamed, 576 size: largeBlob, 577 }, 578 { 579 name: "Push Chunk Streamed 1MB", 580 tfunc: PushChunkStreamed, 581 size: smallBlob, 582 }, 583 { 584 name: "Push Chunk Streamed 10MB", 585 tfunc: PushChunkStreamed, 586 size: mediumBlob, 587 }, 588 { 589 name: "Push Chunk Streamed 100MB", 590 tfunc: PushChunkStreamed, 591 size: largeBlob, 592 }, 593 { 594 name: "Pull 1MB", 595 tfunc: Pull, 596 size: smallBlob, 597 }, 598 { 599 name: "Pull 10MB", 600 tfunc: Pull, 601 size: mediumBlob, 602 }, 603 { 604 name: "Pull 100MB", 605 tfunc: Pull, 606 size: largeBlob, 607 }, 608 { 609 name: "Pull Mixed 20% 1MB, 70% 10MB, 10% 100MB", 610 tfunc: Pull, 611 probabilityRange: normalizeProbabilityRange([]float64{0.2, 0.7, 0.1}), 612 mixedSize: true, 613 }, 614 { 615 name: "Push Monolith Mixed 20% 1MB, 70% 10MB, 10% 100MB", 616 tfunc: PushMonolithStreamed, 617 probabilityRange: normalizeProbabilityRange([]float64{0.2, 0.7, 0.1}), 618 mixedSize: true, 619 }, 620 { 621 name: "Push Chunk Mixed 33% 1MB, 33% 10MB, 33% 100MB", 622 tfunc: PushChunkStreamed, 623 probabilityRange: normalizeProbabilityRange([]float64{0.33, 0.33, 0.33}), 624 mixedSize: true, 625 }, 626 { 627 name: "Pull 75% and Push 25% Mixed 1MB", 628 tfunc: MixedPullAndPush, 629 size: smallBlob, 630 mixedType: true, 631 probabilityRange: normalizeProbabilityRange([]float64{0.75, 0.25}), 632 }, 633 { 634 name: "Pull 75% and Push 25% Mixed 10MB", 635 tfunc: MixedPullAndPush, 636 size: mediumBlob, 637 mixedType: true, 638 probabilityRange: normalizeProbabilityRange([]float64{0.75, 0.25}), 639 }, 640 { 641 name: "Pull 75% and Push 25% Mixed 100MB", 642 tfunc: MixedPullAndPush, 643 size: largeBlob, 644 mixedType: true, 645 probabilityRange: normalizeProbabilityRange([]float64{0.75, 0.25}), 646 }, 647 } 648 649 func Perf( 650 workdir, url, auth, repo string, 651 concurrency int, requests int, 652 outFmt string, srcIPs string, srcCIDR string, skipCleanup bool, 653 ) { 654 json := jsoniter.ConfigCompatibleWithStandardLibrary 655 // logging 656 log.SetFlags(0) 657 log.SetOutput(tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent)) 658 659 // common header 660 log.Printf("Registry URL:\t%s", url) 661 log.Printf("\n") 662 log.Printf("Concurrency Level:\t%v", concurrency) 663 log.Printf("Total requests:\t%v", requests) 664 665 if workdir == "" { 666 cwd, err := os.Getwd() 667 if err != nil { 668 log.Fatal("unable to get current working dir") 669 } 670 671 log.Printf("Working dir:\t%v", cwd) 672 } else { 673 log.Printf("Working dir:\t%v", workdir) 674 } 675 676 log.Printf("\n") 677 678 // initialize test data 679 log.Printf("Preparing test data ...\n") 680 681 setup(workdir) 682 defer teardown(workdir) 683 684 log.Printf("Starting tests ...\n") 685 686 var err error 687 zbError := false 688 689 // get host ips from command line to make requests from 690 var ips []string 691 if len(srcIPs) > 0 { 692 ips = strings.Split(srcIPs, ",") 693 } else if len(srcCIDR) > 0 { 694 ips, err = getIPsFromCIDR(srcCIDR, maxSourceIPs) 695 if err != nil { 696 log.Fatal(err) //nolint: gocritic 697 } 698 } 699 700 for _, tconfig := range testSuite { 701 statsCh := make(chan statsRecord, requests) 702 703 var wg sync.WaitGroup 704 705 summary := newStatsSummary(tconfig.name) 706 707 start := time.Now() 708 709 for c := 0; c < concurrency; c++ { 710 // parallelize with clients 711 wg.Add(1) 712 713 go func() { 714 defer wg.Done() 715 716 httpClient, err := getRandomClientIPs(auth, url, ips) 717 if err != nil { 718 log.Fatal(err) 719 } 720 721 err = tconfig.tfunc(workdir, url, repo, requests/concurrency, tconfig, statsCh, httpClient, skipCleanup) 722 if err != nil { 723 log.Fatal(err) 724 } 725 }() 726 } 727 wg.Wait() 728 729 summary.total = time.Since(start) 730 summary.rps = float32(requests) / float32(summary.total.Seconds()) 731 732 if tconfig.mixedSize || tconfig.size == 0 { 733 summary.mixedSize = true 734 } 735 736 if tconfig.mixedType { 737 summary.mixedType = true 738 } 739 740 for count := 0; count < requests; count++ { 741 record := <-statsCh 742 updateStats(&summary, record) 743 } 744 745 sort.Sort(Durations(summary.latencies)) 746 747 printStats(requests, &summary, outFmt) 748 749 if summary.errors != 0 && !zbError { 750 zbError = true 751 } 752 } 753 754 if outFmt == cicdFmt { 755 jsonOut, err := json.Marshal(cicdSummary) 756 if err != nil { 757 log.Fatal(err) // file closed on exit 758 } 759 760 if err := os.WriteFile(fmt.Sprintf("%s.json", outFmt), jsonOut, defaultFilePerms); err != nil { 761 log.Fatal(err) 762 } 763 } 764 765 if zbError { 766 os.Exit(1) 767 } 768 } 769 770 // getRandomClientIPs returns a resty client with a random bind address from ips slice. 771 func getRandomClientIPs(auth string, url string, ips []string) (*resty.Client, error) { 772 client := resty.New() 773 774 if auth != "" { 775 creds := strings.Split(auth, ":") 776 client.SetBasicAuth(creds[0], creds[1]) 777 } 778 779 // get random ip client 780 if len(ips) != 0 { 781 // get random number 782 nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(ips)))) 783 if err != nil { 784 return nil, err 785 } 786 787 // get random ip 788 ip := ips[nBig.Int64()] 789 790 // set ip in transport 791 localAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:0", ip)) 792 if err != nil { 793 return nil, err 794 } 795 796 transport := &http.Transport{ 797 Proxy: http.ProxyFromEnvironment, 798 DialContext: (&net.Dialer{ 799 Timeout: httpTimeout, 800 KeepAlive: httpKeepAlive, 801 LocalAddr: localAddr, 802 }).DialContext, 803 TLSHandshakeTimeout: TLSHandshakeTimeout, 804 } 805 806 client.SetTransport(transport) 807 } 808 809 parsedURL, err := urlparser.Parse(url) 810 if err != nil { 811 log.Fatal(err) 812 } 813 814 //nolint: gosec 815 if parsedURL.Scheme == secureProtocol { 816 client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) 817 } 818 819 return client, nil 820 } 821 822 // getIPsFromCIDR returns a list of ips given a cidr. 823 func getIPsFromCIDR(cidr string, maxIPs int) ([]string, error) { 824 //nolint:varnamelen 825 ip, ipnet, err := net.ParseCIDR(cidr) 826 if err != nil { 827 return nil, err 828 } 829 830 var ips []string 831 for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip) && len(ips) < maxIPs; inc(ip) { 832 ips = append(ips, ip.String()) 833 } 834 // remove network address and broadcast address 835 return ips[1 : len(ips)-1], nil 836 } 837 838 // https://go.dev/play/p/sdzcMvZYWnc 839 func inc(ip net.IP) { 840 for j := len(ip) - 1; j >= 0; j-- { 841 ip[j]++ 842 if ip[j] > 0 { 843 break 844 } 845 } 846 }