feat: Compress and extract slim binaries with zstd (#2533)

Fixes #2202

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Mathias Fredriksson 2022-06-21 19:53:36 +03:00 committed by GitHub
parent 64f0473499
commit e2785ada5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 377 additions and 16 deletions

View File

@ -370,6 +370,9 @@ jobs:
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
- name: Install zstd
run: sudo apt-get install -y zstd
- name: Build site
run: make -B site/out/index.html
@ -382,6 +385,7 @@ jobs:
# build slim binaries
./scripts/build_go_slim.sh \
--output ./dist/ \
--compress 22 \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64

View File

@ -68,6 +68,9 @@ jobs:
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
- name: Install zstd
run: sudo apt-get install -y zstd
- name: Build Site
run: make site/out/index.html
@ -80,6 +83,7 @@ jobs:
# build slim binaries
./scripts/build_go_slim.sh \
--output ./dist/ \
--compress 22 \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64
@ -198,6 +202,9 @@ jobs:
brew tap mitchellh/gon
brew install mitchellh/gon/gon
# Used for compressing embedded slim binaries
brew install zstd
- name: Build Site
run: make site/out/index.html
@ -210,6 +217,7 @@ jobs:
# build slim binaries
./scripts/build_go_slim.sh \
--output ./dist/ \
--compress 22 \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \
darwin:amd64,arm64

View File

@ -35,6 +35,7 @@ build: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name
# build slim artifacts and copy them to the site output directory
./scripts/build_go_slim.sh \
--version "$(VERSION)" \
--compress 6 \
--output ./dist/ \
linux:amd64,armv7,arm64 \
windows:amd64,arm64 \

View File

@ -254,6 +254,7 @@ func server() *cobra.Command {
Logger: logger.Named("coderd"),
Database: databasefake.New(),
Pubsub: database.NewPubsubInMemory(),
CacheDir: cacheDir,
GoogleTokenValidator: googleTokenValidator,
SecureAuthCookie: secureAuthCookie,
SSHKeygenAlgorithm: sshKeygenAlgorithm,

View File

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"sync"
"time"
@ -42,6 +43,9 @@ type Options struct {
Database database.Store
Pubsub database.Pubsub
// CacheDir is used for caching files served by the API.
CacheDir string
AgentConnectionUpdateFrequency time.Duration
// APIRateLimit is the minutely throughput rate limit per user or ip.
// Setting a rate limit <0 will disable the rate limiter across the entire
@ -78,11 +82,20 @@ func New(options *Options) *API {
}
}
siteCacheDir := options.CacheDir
if siteCacheDir != "" {
siteCacheDir = filepath.Join(siteCacheDir, "site")
}
binFS, err := site.ExtractOrReadBinFS(siteCacheDir, site.FS())
if err != nil {
panic(xerrors.Errorf("read site bin failed: %w", err))
}
r := chi.NewRouter()
api := &API{
Options: options,
Handler: r,
siteHandler: site.Handler(site.FS()),
siteHandler: site.Handler(site.FS(), binFS),
}
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)

View File

@ -3,7 +3,7 @@
# This script builds multiple "slim" Go binaries for Coder with the given OS and
# architecture combinations. This wraps ./build_go_matrix.sh.
#
# Usage: ./build_go_slim.sh [--version 1.2.3-devel+abcdef] [--output dist/] os1:arch1,arch2 os2:arch1 os1:arch3
# Usage: ./build_go_slim.sh [--version 1.2.3-devel+abcdef] [--output dist/] [--compress 22] os1:arch1,arch2 os2:arch1 os1:arch3
#
# If no OS:arch combinations are provided, nothing will happen and no error will
# be returned. If no version is specified, defaults to the version from
@ -15,6 +15,10 @@
#
# The built binaries are additionally copied to the site output directory so
# they can be packaged into non-slim binaries correctly.
#
# When the --compress <level> parameter is provided, the binaries in site/bin
# will be compressed using zstd into site/bin/coder.tar.zst, this helps reduce
# final binary size significantly.
set -euo pipefail
shopt -s nullglob
@ -23,8 +27,9 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
version=""
output_path=""
compress=0
args="$(getopt -o "" -l version:,output: -- "$@")"
args="$(getopt -o "" -l version:,output:,compress: -- "$@")"
eval set -- "$args"
while true; do
case "$1" in
@ -36,6 +41,10 @@ while true; do
output_path="$2"
shift 2
;;
--compress)
compress="$2"
shift 2
;;
--)
shift
break
@ -48,6 +57,13 @@ done
# Check dependencies
dependencies go
if [[ $compress != 0 ]]; then
dependencies tar zstd
if [[ $compress != [0-9]* ]] || [[ $compress -gt 22 ]] || [[ $compress -lt 1 ]]; then
error "Invalid value for compress, must in in the range of [1, 22]"
fi
fi
# Remove the "v" prefix.
version="${version#v}"
@ -92,3 +108,12 @@ for f in ./coder-slim_*; do
dest="$dest_dir/$hyphenated"
cp "$f" "$dest"
done
if [[ $compress != 0 ]]; then
log "--- Compressing coder-slim binaries using zstd level $compress ($dest_dir/coder.tar.zst)"
pushd "$dest_dir"
tar cf coder.tar coder-*
rm coder-*
zstd --force --ultra --long -"${compress}" --rm --no-progress coder.tar -o coder.tar.zst
popd
fi

View File

@ -1,13 +1,15 @@
package site
import (
"archive/tar"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
@ -15,7 +17,9 @@ import (
"time"
"github.com/justinas/nosurf"
"github.com/klauspost/compress/zstd"
"github.com/unrolled/secure"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
)
@ -29,22 +33,25 @@ func WithAPIResponse(ctx context.Context, apiResponse APIResponse) context.Conte
}
// Handler returns an HTTP handler for serving the static site.
func Handler(fileSystem fs.FS) http.Handler {
func Handler(siteFS fs.FS, binFS http.FileSystem) http.Handler {
// html files are handled by a text/template. Non-html files
// are served by the default file server.
//
// REMARK: text/template is needed to inject values on each request like
// CSRF.
files, err := htmlFiles(fileSystem)
files, err := htmlFiles(siteFS)
if err != nil {
panic(xerrors.Errorf("Failed to return handler for static files. Html files failed to load: %w", err))
}
mux := http.NewServeMux()
mux.Handle("/bin/", http.StripPrefix("/bin", http.FileServer(binFS)))
mux.Handle("/", http.FileServer(http.FS(siteFS))) // All other non-html static files.
return secureHeaders(&handler{
fs: fileSystem,
fs: siteFS,
htmlFiles: files,
h: http.FileServer(http.FS(fileSystem)), // All other non-html static files
h: mux,
})
}
@ -146,8 +153,13 @@ func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
return
}
switch {
// If requesting binaries, serve straight up.
case reqFile == "bin" || strings.HasPrefix(reqFile, "bin/"):
h.h.ServeHTTP(resp, req)
return
// If the original file path exists we serve it.
if h.exists(reqFile) {
case h.exists(reqFile):
if ShouldCacheFile(reqFile) {
resp.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
@ -357,7 +369,6 @@ func htmlFiles(files fs.FS) (*htmlTemplates, error) {
return nil
})
if err != nil {
return nil, err
}
@ -366,3 +377,135 @@ func htmlFiles(files fs.FS) (*htmlTemplates, error) {
tpls: root,
}, nil
}
// ExtractOrReadBinFS checks the provided fs for compressed coder
// binaries and extracts them into dest/bin if found. As a fallback,
// the provided FS is checked for a /bin directory, if it is non-empty
// it is returned. Finally dest/bin is returned as a fallback allowing
// binaries to be manually placed in dest (usually
// ${CODER_CACHE_DIRECTORY}/site/bin).
func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, error) {
if dest == "" {
// No destination on fs, embedded fs is the only option.
binFS, err := fs.Sub(siteFS, "bin")
if err != nil {
return nil, xerrors.Errorf("cache path is empty and embedded fs does not have /bin: %w", err)
}
return http.FS(binFS), nil
}
dest = filepath.Join(dest, "bin")
mkdest := func() (http.FileSystem, error) {
err := os.MkdirAll(dest, 0o700)
if err != nil {
return nil, xerrors.Errorf("mkdir failed: %w", err)
}
return http.Dir(dest), nil
}
archive, err := siteFS.Open("bin/coder.tar.zst")
if err != nil {
if xerrors.Is(err, fs.ErrNotExist) {
files, err := fs.ReadDir(siteFS, "bin")
if err != nil {
if xerrors.Is(err, fs.ErrNotExist) {
// Given fs does not have a bin directory,
// serve from cache directory.
return mkdest()
}
return nil, xerrors.Errorf("site fs read dir failed: %w", err)
}
if len(filterFiles(files, "GITKEEP")) > 0 {
// If there are other files than bin/GITKEEP,
// serve the files.
binFS, err := fs.Sub(siteFS, "bin")
if err != nil {
return nil, xerrors.Errorf("site fs sub dir failed: %w", err)
}
return http.FS(binFS), nil
}
// Nothing we can do, serve the cache directory,
// thus allowing binaries to be places there.
return mkdest()
}
return nil, xerrors.Errorf("open coder binary archive failed: %w", err)
}
defer archive.Close()
dir, err := mkdest()
if err != nil {
return nil, err
}
n, err := extractBin(dest, archive)
if err != nil {
return nil, xerrors.Errorf("extract coder binaries failed: %w", err)
}
if n == 0 {
return nil, xerrors.New("no files were extracted from coder binaries archive")
}
return dir, nil
}
func filterFiles(files []fs.DirEntry, names ...string) []fs.DirEntry {
var filtered []fs.DirEntry
for _, f := range files {
if slices.Contains(names, f.Name()) {
continue
}
filtered = append(filtered, f)
}
return filtered
}
func extractBin(dest string, r io.Reader) (numExtraced int, err error) {
opts := []zstd.DOption{
// Concurrency doesn't help us when decoding the tar and
// can actually slow us down.
zstd.WithDecoderConcurrency(1),
// Ignoring checksums can give a slight performance
// boost but it's probalby not worth the reduced safety.
zstd.IgnoreChecksum(false),
// Allow the decoder to use more memory giving us a 2-3x
// performance boost.
zstd.WithDecoderLowmem(false),
}
zr, err := zstd.NewReader(r, opts...)
if err != nil {
return 0, xerrors.Errorf("open zstd archive failed: %w", err)
}
defer zr.Close()
tr := tar.NewReader(zr)
n := 0
for {
h, err := tr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
return n, nil
}
return n, xerrors.Errorf("read tar archive failed: %w", err)
}
name := filepath.Join(dest, filepath.Base(h.Name))
f, err := os.Create(name)
if err != nil {
return n, xerrors.Errorf("create file failed: %w", err)
}
//#nosec // We created this tar, no risk of decompression bomb.
_, err = io.Copy(f, tr)
if err != nil {
_ = f.Close()
return n, xerrors.Errorf("write file contents failed: %w", err)
}
err = f.Close()
if err != nil {
return n, xerrors.Errorf("close file failed: %w", err)
}
n++
}
}

View File

@ -7,10 +7,12 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/site"
@ -36,8 +38,9 @@ func TestCaching(t *testing.T) {
Data: []byte("folderFile"),
},
}
binFS := http.FS(fstest.MapFS{})
srv := httptest.NewServer(site.Handler(rootFS))
srv := httptest.NewServer(site.Handler(rootFS, binFS))
defer srv.Close()
// Create a context
@ -95,15 +98,16 @@ func TestServingFiles(t *testing.T) {
Data: []byte("dashboard-css-bytes"),
},
}
binFS := http.FS(fstest.MapFS{})
srv := httptest.NewServer(site.Handler(rootFS))
srv := httptest.NewServer(site.Handler(rootFS, binFS))
defer srv.Close()
// Create a context
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelFunc()
var testCases = []struct {
testCases := []struct {
path string
expected string
}{
@ -150,7 +154,7 @@ func TestServingFiles(t *testing.T) {
func TestShouldCacheFile(t *testing.T) {
t.Parallel()
var testCases = []struct {
testCases := []struct {
reqFile string
expected bool
}{
@ -171,6 +175,167 @@ func TestShouldCacheFile(t *testing.T) {
}
}
func TestServingBin(t *testing.T) {
t.Parallel()
// Create a misc rootfs for realistic test.
rootFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("index-bytes"),
},
"favicon.ico": &fstest.MapFile{
Data: []byte("favicon-bytes"),
},
"dashboard.js": &fstest.MapFile{
Data: []byte("dashboard-js-bytes"),
},
"dashboard.css": &fstest.MapFile{
Data: []byte("dashboard-css-bytes"),
},
}
type req struct {
url string
wantStatus int
wantBody []byte
}
tests := []struct {
name string
fs fstest.MapFS
reqs []req
wantErr bool
}{
{
name: "Extract and serve bin",
fs: fstest.MapFS{
"bin/coder.tar.zst": &fstest.MapFile{
// echo iamcoder >coder-linux-amd64
// tar cf coder.tar coder-linux-amd64
// zstd --long --ultra -22 coder.tar
Data: []byte{
0x28, 0xb5, 0x2f, 0xfd, 0x64, 0x00, 0x27, 0xf5, 0x02, 0x00, 0x12, 0xc4,
0x0e, 0x16, 0xa0, 0xb5, 0x39, 0x00, 0xe8, 0x67, 0x59, 0xaf, 0xe3, 0xdd,
0x8d, 0xfe, 0x47, 0xe8, 0x9d, 0x9c, 0x44, 0x0b, 0x75, 0x70, 0x61, 0x52,
0x0d, 0x56, 0xaa, 0x16, 0xb9, 0x5a, 0x0a, 0x4b, 0x40, 0xd2, 0x7a, 0x05,
0xd1, 0xd7, 0xe3, 0xf9, 0xf9, 0x07, 0xef, 0xda, 0x77, 0x04, 0xff, 0xe8,
0x7a, 0x94, 0x56, 0x9a, 0x40, 0x3b, 0x94, 0x61, 0x18, 0x91, 0x90, 0x21,
0x0c, 0x00, 0xf3, 0xc5, 0xe5, 0xd8, 0x80, 0x10, 0x06, 0x0a, 0x08, 0x86,
0xb2, 0x00, 0x60, 0x12, 0x70, 0xd3, 0x51, 0x05, 0x04, 0x20, 0x16, 0x2c,
0x79, 0xad, 0x01, 0xc0, 0xf5, 0x28, 0x08, 0x03, 0x1c, 0x4c, 0x84, 0xf4,
},
},
},
reqs: []req{
{url: "/bin/coder-linux-amd64", wantStatus: http.StatusOK, wantBody: []byte("iamcoder\n")},
{url: "/bin/GITKEEP", wantStatus: http.StatusNotFound},
},
},
{
name: "Error on invalid archive",
fs: fstest.MapFS{
"bin/coder.tar.zst": &fstest.MapFile{
Data: []byte{
0x28, 0xb5, 0x2f, 0xfd, 0x64, 0x00, 0x27, 0xf5, 0x02, 0x00, 0x12, 0xc4,
0x0e, 0x16, 0xa0, 0xb5, 0x39, 0x00, 0xe8, 0x67, 0x59, 0xaf, 0xe3, 0xdd,
0x8d, 0xfe, 0x47, 0xe8, 0x9d, 0x9c, 0x44, 0x0b, 0x75, 0x70, 0x61, 0x52,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Zeroed from above test.
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Zeroed from above test.
0x7a, 0x94, 0x56, 0x9a, 0x40, 0x3b, 0x94, 0x61, 0x18, 0x91, 0x90, 0x21,
0x0c, 0x00, 0xf3, 0xc5, 0xe5, 0xd8, 0x80, 0x10, 0x06, 0x0a, 0x08, 0x86,
0xb2, 0x00, 0x60, 0x12, 0x70, 0xd3, 0x51, 0x05, 0x04, 0x20, 0x16, 0x2c,
0x79, 0xad, 0x01, 0xc0, 0xf5, 0x28, 0x08, 0x03, 0x1c, 0x4c, 0x84, 0xf4,
},
},
},
wantErr: true,
},
{
name: "Error on empty archive",
fs: fstest.MapFS{
"bin/coder.tar.zst": &fstest.MapFile{Data: []byte{}},
},
wantErr: true,
},
{
name: "Serve local fs",
fs: fstest.MapFS{
// Only GITKEEP file on embedded fs, won't be served.
"bin/GITKEEP": &fstest.MapFile{},
},
reqs: []req{
{url: "/bin/coder-linux-amd64", wantStatus: http.StatusNotFound},
{url: "/bin/GITKEEP", wantStatus: http.StatusNotFound},
},
},
{
name: "Serve local fs when embedd fs empty",
fs: fstest.MapFS{},
reqs: []req{
{url: "/bin/coder-linux-amd64", wantStatus: http.StatusNotFound},
{url: "/bin/GITKEEP", wantStatus: http.StatusNotFound},
},
},
{
name: "Serve embedd fs",
fs: fstest.MapFS{
"bin/GITKEEP": &fstest.MapFile{
Data: []byte(""),
},
"bin/coder-linux-amd64": &fstest.MapFile{
Data: []byte("embedd"),
},
},
reqs: []req{
{url: "/bin/coder-linux-amd64", wantStatus: http.StatusOK, wantBody: []byte("embedd")},
{url: "/bin/GITKEEP", wantStatus: http.StatusOK, wantBody: []byte("")},
},
},
}
//nolint // Parallel test detection issue.
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dest := t.TempDir()
binFS, err := site.ExtractOrReadBinFS(dest, tt.fs)
if !tt.wantErr && err != nil {
require.NoError(t, err, "extract or read failed")
} else if tt.wantErr {
require.Error(t, err, "extraction or read did not fail")
}
srv := httptest.NewServer(site.Handler(rootFS, binFS))
defer srv.Close()
// Create a context
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelFunc()
for _, tr := range tt.reqs {
t.Run(strings.TrimPrefix(tr.url, "/"), func(t *testing.T) {
req, err := http.NewRequestWithContext(ctx, "GET", srv.URL+tr.url, nil)
require.NoError(t, err, "http request failed")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "http do failed")
defer resp.Body.Close()
gotStatus := resp.StatusCode
gotBody, _ := io.ReadAll(resp.Body)
if tr.wantStatus > 0 {
assert.Equal(t, tr.wantStatus, gotStatus, "status did not match")
}
if tr.wantBody != nil {
assert.Equal(t, string(tr.wantBody), string(gotBody), "body did not match")
}
})
}
})
}
}
func TestServeAPIResponse(t *testing.T) {
t.Parallel()
@ -180,6 +345,7 @@ func TestServeAPIResponse(t *testing.T) {
Data: []byte(`{"code":{{ .APIResponse.StatusCode }},"message":"{{ .APIResponse.Message }}"}`),
},
}
binFS := http.FS(fstest.MapFS{})
apiResponse := site.APIResponse{
StatusCode: http.StatusBadGateway,
@ -187,7 +353,7 @@ func TestServeAPIResponse(t *testing.T) {
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(site.WithAPIResponse(r.Context(), apiResponse))
site.Handler(rootFS).ServeHTTP(w, r)
site.Handler(rootFS, binFS).ServeHTTP(w, r)
}))
defer srv.Close()