github.com/mweagle/Sparta@v1.15.0/profile_loop_build.go (about)

     1  // +build !lambdabinary
     2  
     3  package sparta
     4  
     5  import (
     6  	"fmt"
     7  	"os"
     8  	"path"
     9  	"path/filepath"
    10  	"sort"
    11  	"strings"
    12  	"time"
    13  
    14  	survey "github.com/AlecAivazis/survey/v2"
    15  	"github.com/aws/aws-sdk-go/aws"
    16  	"github.com/aws/aws-sdk-go/aws/session"
    17  	"github.com/aws/aws-sdk-go/service/s3"
    18  	"github.com/aws/aws-sdk-go/service/s3/s3manager"
    19  	"github.com/google/pprof/driver"
    20  	"github.com/google/pprof/profile"
    21  	spartaAWS "github.com/mweagle/Sparta/aws"
    22  	spartaCF "github.com/mweagle/Sparta/aws/cloudformation"
    23  	gocf "github.com/mweagle/go-cloudformation"
    24  	"github.com/pkg/errors"
    25  	"github.com/sirupsen/logrus"
    26  )
    27  
    28  type userAnswers struct {
    29  	StackName            string `survey:"stackName"`
    30  	StackInstance        string
    31  	ProfileType          string `survey:"profileType"`
    32  	DownloadNewSnapshots string `survey:"downloadNewSnapshots"`
    33  	ProfileOptions       []string
    34  	RefreshSnapshots     bool
    35  }
    36  
    37  func cachedProfileNames() []string {
    38  	globPattern := filepath.Join(ScratchDirectory, "*.profile")
    39  	matchingFiles, matchingFilesErr := filepath.Glob(globPattern)
    40  	if matchingFilesErr != nil {
    41  		return []string{}
    42  	}
    43  	// Just get the base name of the profile...
    44  	cachedNames := []string{}
    45  	for _, eachMatch := range matchingFiles {
    46  		baseName := path.Base(eachMatch)
    47  		filenameParts := strings.Split(baseName, ".")
    48  		cachedNames = append(cachedNames, filenameParts[0])
    49  	}
    50  	return cachedNames
    51  }
    52  
    53  func askQuestions(userStackName string, stackNameToIDMap map[string]string) (*userAnswers, error) {
    54  	stackNames := []string{}
    55  	for eachKey := range stackNameToIDMap {
    56  		stackNames = append(stackNames, eachKey)
    57  	}
    58  	sort.Strings(stackNames)
    59  	cachedProfiles := cachedProfileNames()
    60  	sort.Strings(cachedProfiles)
    61  
    62  	var qs = []*survey.Question{
    63  		{
    64  			Name: "stackName",
    65  			Prompt: &survey.Select{
    66  				Message: "Which stack would you like to profile:",
    67  				Options: stackNames,
    68  				Default: userStackName,
    69  			},
    70  		},
    71  		{
    72  			Name: "profileType",
    73  			Prompt: &survey.Select{
    74  				Message: "What type of profile would you like to view?",
    75  				Options: profileTypes,
    76  				Default: profileTypes[0],
    77  			},
    78  		},
    79  	}
    80  
    81  	// Ask the known questions, figure out if they want to download a new
    82  	// version of the snapshots...
    83  	var responses userAnswers
    84  	responseError := survey.Ask(qs, &responses)
    85  	if responseError != nil {
    86  		return nil, responseError
    87  	}
    88  	responses.StackInstance = stackNameToIDMap[responses.StackName]
    89  
    90  	// Based on the first set, ask whether then want to download a new snapshot
    91  	cachedProfileExists := strings.Contains(strings.Join(cachedProfiles, " "), responses.ProfileType)
    92  
    93  	refreshCacheOptions := []string{}
    94  	if cachedProfileExists {
    95  		refreshCacheOptions = append(refreshCacheOptions, "Use cached snapshot")
    96  	}
    97  	refreshCacheOptions = append(refreshCacheOptions, "Download new snapshots from S3")
    98  	var questionsRefresh = []*survey.Question{
    99  		{
   100  			Name: "downloadNewSnapshots",
   101  			Prompt: &survey.Select{
   102  				Message: "What profile snapshot(s) would you like to view?",
   103  				Options: refreshCacheOptions,
   104  				Default: refreshCacheOptions[0],
   105  			},
   106  		},
   107  	}
   108  	var refreshAnswers userAnswers
   109  	refreshQuestionError := survey.Ask(questionsRefresh, &refreshAnswers)
   110  	if refreshQuestionError != nil {
   111  		return nil, refreshQuestionError
   112  	}
   113  	responses.RefreshSnapshots = (refreshAnswers.DownloadNewSnapshots == "Download new snapshots from S3")
   114  
   115  	// Final set of questions regarding heap information
   116  	// If this is a memory profile, what kind?
   117  	if responses.ProfileType == "heap" {
   118  		// the answers will be written to this struct
   119  		heapAnswers := struct {
   120  			Type string `survey:"type"`
   121  		}{}
   122  		// the questions to ask
   123  		var heapQuestions = []*survey.Question{
   124  			{
   125  				Name: "type",
   126  				Prompt: &survey.Select{
   127  					Message: "Please select a heap profile type:",
   128  					Options: []string{"inuse_space", "inuse_objects", "alloc_space", "alloc_objects"},
   129  					Default: "inuse_space",
   130  				},
   131  			},
   132  		}
   133  		// perform the questions
   134  		heapErr := survey.Ask(heapQuestions, &heapAnswers)
   135  		if heapErr != nil {
   136  			return nil, heapErr
   137  		}
   138  		responses.ProfileOptions = []string{fmt.Sprintf("-%s", heapAnswers.Type)}
   139  	}
   140  	return &responses, nil
   141  }
   142  
   143  func objectKeysForProfileType(profileType string,
   144  	stackName string,
   145  	s3BucketName string,
   146  	maxCount int64,
   147  	awsSession *session.Session,
   148  	logger *logrus.Logger) ([]string, error) {
   149  	// http://weagle.s3.amazonaws.com/gosparta.io/pprof/SpartaPPropStack/profiles/cpu/cpu.42.profile
   150  
   151  	// gosparta.io/pprof/SpartaPPropStack/profiles/cpu/cpu.42.profile
   152  	// List all these...
   153  	rootPath := profileSnapshotRootKeypathForType(profileType, stackName)
   154  	listObjectInput := &s3.ListObjectsInput{
   155  		Bucket: aws.String(s3BucketName),
   156  		//	Delimiter: aws.String("/"),
   157  		Prefix:  aws.String(rootPath),
   158  		MaxKeys: aws.Int64(maxCount),
   159  	}
   160  	allItems := []string{}
   161  	s3Svc := s3.New(awsSession)
   162  	for {
   163  		listItemResults, listItemResultsErr := s3Svc.ListObjects(listObjectInput)
   164  		if listItemResultsErr != nil {
   165  			return nil, errors.Wrapf(listItemResultsErr, "Attempting to list bucket: %s", s3BucketName)
   166  		}
   167  		for _, eachEntry := range listItemResults.Contents {
   168  			logger.WithFields(logrus.Fields{
   169  				"FoundItem": *eachEntry.Key,
   170  				"Size":      *eachEntry.Size,
   171  			}).Debug("Profile file")
   172  		}
   173  
   174  		for _, eachItem := range listItemResults.Contents {
   175  			if *eachItem.Size > 0 {
   176  				allItems = append(allItems, *eachItem.Key)
   177  			}
   178  		}
   179  		if int64(len(allItems)) >= maxCount || listItemResults.NextMarker == nil {
   180  			return allItems, nil
   181  		}
   182  		listObjectInput.Marker = listItemResults.NextMarker
   183  	}
   184  }
   185  
   186  ////////////////////////////////////////////////////////////////////////////////
   187  // Type returned from worker pool pulling down S3 snapshots
   188  type downloadResult struct {
   189  	err           error
   190  	localFilePath string
   191  }
   192  
   193  func (dr *downloadResult) Error() error {
   194  	return dr.err
   195  }
   196  func (dr *downloadResult) Result() interface{} {
   197  	return dr.localFilePath
   198  }
   199  
   200  var _ workResult = (*downloadResult)(nil)
   201  
   202  func downloaderTask(profileType string,
   203  	stackName string,
   204  	bucketName string,
   205  	cacheRootPath string,
   206  	downloadKey string,
   207  	s3Service *s3.S3,
   208  	downloader *s3manager.Downloader,
   209  	logger *logrus.Logger) taskFunc {
   210  
   211  	return func() workResult {
   212  		downloadInput := &s3.GetObjectInput{
   213  			Bucket: aws.String(bucketName),
   214  			Key:    aws.String(downloadKey),
   215  		}
   216  		cachedFilename := filepath.Join(cacheRootPath, filepath.Base(downloadKey))
   217  		outputFile, outputFileErr := os.Create(cachedFilename)
   218  		if outputFileErr != nil {
   219  			return &downloadResult{
   220  				err: outputFileErr,
   221  			}
   222  		}
   223  		defer func() {
   224  			closeErr := outputFile.Close()
   225  			if closeErr != nil {
   226  				logger.WithFields(logrus.Fields{
   227  					"error": closeErr,
   228  				}).Warn("Failed to close output file writer")
   229  			}
   230  		}()
   231  
   232  		_, downloadErr := downloader.Download(outputFile, downloadInput)
   233  		// If we're all good, delete the one on s3...
   234  		if downloadErr == nil {
   235  			deleteObjectInput := &s3.DeleteObjectInput{
   236  				Bucket: aws.String(bucketName),
   237  				Key:    aws.String(downloadKey),
   238  			}
   239  			_, deleteErr := s3Service.DeleteObject(deleteObjectInput)
   240  			if deleteErr != nil {
   241  				logger.WithFields(logrus.Fields{
   242  					"Error": deleteErr,
   243  				}).Warn("Failed to delete S3 profile snapshot")
   244  			} else {
   245  				logger.WithFields(logrus.Fields{
   246  					"Bucket": bucketName,
   247  					"Key":    downloadKey,
   248  				}).Debug("Deleted S3 profile")
   249  			}
   250  		}
   251  		return &downloadResult{
   252  			err:           downloadErr,
   253  			localFilePath: outputFile.Name(),
   254  		}
   255  	}
   256  }
   257  
   258  func syncStackProfileSnapshots(profileType string,
   259  	refreshSnapshots bool,
   260  	stackName string,
   261  	stackInstance string,
   262  	s3BucketName string,
   263  	awsSession *session.Session,
   264  	logger *logrus.Logger) ([]string, error) {
   265  	s3KeyRoot := profileSnapshotRootKeypathForType(profileType, stackName)
   266  
   267  	if !refreshSnapshots {
   268  		cachedProfilePath := cachedAggregatedProfilePath(profileType)
   269  		// Just used the cached ones...
   270  		logger.WithFields(logrus.Fields{
   271  			"CachedProfile": cachedProfilePath,
   272  		}).Info("Using cached profiles")
   273  
   274  		// Make sure they exist...
   275  		_, cachedInfoErr := os.Stat(cachedProfilePath)
   276  		if os.IsNotExist(cachedInfoErr) {
   277  			return nil, fmt.Errorf("no cache files found for profile type: %s. Please run again and fetch S3 artifacts", profileType)
   278  		}
   279  		return []string{cachedProfilePath}, nil
   280  	}
   281  	// Rebuild the cache...
   282  	cacheRoot := cacheDirectoryForProfileType(profileType, stackName)
   283  	logger.WithFields(logrus.Fields{
   284  		"StackName":      stackName,
   285  		"S3Bucket":       s3BucketName,
   286  		"ProfileRootKey": s3KeyRoot,
   287  		"Type":           profileType,
   288  		"CacheRoot":      cacheRoot,
   289  	}).Info("Refreshing cached profiles")
   290  
   291  	removeErr := os.RemoveAll(cacheRoot)
   292  	if removeErr != nil {
   293  		return nil, errors.Wrapf(removeErr, "Attempting delete local directory: %s", cacheRoot)
   294  	}
   295  	mkdirErr := os.MkdirAll(cacheRoot, os.ModePerm)
   296  	if nil != mkdirErr {
   297  		return nil, errors.Wrapf(mkdirErr, "Attempting to create local directory: %s", cacheRoot)
   298  	}
   299  
   300  	// Ok, let's get some user information
   301  	s3Svc := s3.New(awsSession)
   302  	downloader := s3manager.NewDownloader(awsSession)
   303  	downloadKeys, downloadKeysErr := objectKeysForProfileType(profileType,
   304  		stackName,
   305  		s3BucketName,
   306  		1024,
   307  		awsSession,
   308  		logger)
   309  
   310  	if downloadKeys != nil {
   311  		return nil, errors.Wrapf(downloadKeysErr,
   312  			"Failed to determine pprof download keys")
   313  	}
   314  	downloadTasks := make([]*workTask, len(downloadKeys))
   315  	for index, eachKey := range downloadKeys {
   316  		taskFunc := downloaderTask(profileType,
   317  			stackName,
   318  			s3BucketName,
   319  			cacheRoot,
   320  			eachKey,
   321  			s3Svc,
   322  			downloader,
   323  			logger)
   324  		downloadTasks[index] = newWorkTask(taskFunc)
   325  	}
   326  	p := newWorkerPool(downloadTasks, 8)
   327  	results, runErrors := p.Run()
   328  	if len(runErrors) > 0 {
   329  		return nil, fmt.Errorf("errors reported: %#v", runErrors)
   330  	}
   331  
   332  	// Read them all and merge them into a single profile...
   333  	var accumulatedProfiles []*profile.Profile
   334  	for _, eachResult := range results {
   335  		profileFile := eachResult.(string)
   336  		/* #nosec */
   337  		profileInput, profileInputErr := os.Open(profileFile)
   338  		if profileInputErr != nil {
   339  			return nil, profileInputErr
   340  		}
   341  		parsedProfile, parsedProfileErr := profile.Parse(profileInput)
   342  		// Ignore broken profiles
   343  		if parsedProfileErr != nil {
   344  			logger.WithFields(logrus.Fields{
   345  				"Path":  eachResult,
   346  				"Error": parsedProfileErr,
   347  			}).Warn("Invalid cached profile")
   348  		} else {
   349  			logger.WithFields(logrus.Fields{
   350  				"Input": profileFile,
   351  			}).Info("Aggregating profile")
   352  			accumulatedProfiles = append(accumulatedProfiles, parsedProfile)
   353  			profileInputCloseErr := profileInput.Close()
   354  			if profileInputCloseErr != nil {
   355  				logger.WithFields(logrus.Fields{
   356  					"error": profileInputCloseErr,
   357  				}).Warn("Failed to close profile file writer")
   358  			}
   359  		}
   360  	}
   361  	logger.WithFields(logrus.Fields{
   362  		"ProfileCount": len(accumulatedProfiles),
   363  	}).Info("Consolidating profiles")
   364  
   365  	if len(accumulatedProfiles) <= 0 {
   366  		return nil, fmt.Errorf("unable to find %s snapshots in s3://%s for profile type: %s",
   367  			stackName,
   368  			s3BucketName,
   369  			profileType)
   370  	}
   371  
   372  	// Great, merge them all
   373  	consolidatedProfile, consolidatedProfileErr := profile.Merge(accumulatedProfiles)
   374  	if consolidatedProfileErr != nil {
   375  		return nil, fmt.Errorf("failed to merge profiles: %s", consolidatedProfileErr.Error())
   376  	}
   377  	// Write it out as the "canonical" path...
   378  	consolidatedPath := cachedAggregatedProfilePath(profileType)
   379  	logger.WithFields(logrus.Fields{
   380  		"ConsolidatedProfile": consolidatedPath,
   381  	}).Info("Creating consolidated profile")
   382  
   383  	outputFile, outputFileErr := os.Create(consolidatedPath)
   384  	if outputFileErr != nil {
   385  		return nil, errors.Wrapf(outputFileErr,
   386  			"failed to create consolidated file: %s", consolidatedPath)
   387  	}
   388  	writeErr := consolidatedProfile.Write(outputFile)
   389  	if writeErr != nil {
   390  		return nil, errors.Wrapf(writeErr,
   391  			"failed to write profile: %s", consolidatedPath)
   392  	}
   393  
   394  	// Delete all the other ones, just return the consolidated one...
   395  	for _, eachResult := range results {
   396  		unlinkErr := os.Remove(eachResult.(string))
   397  		if unlinkErr != nil {
   398  			logger.WithFields(logrus.Fields{
   399  				"File":  consolidatedPath,
   400  				"Error": unlinkErr,
   401  			}).Info("Failed to delete file")
   402  		}
   403  		outputFileErr := outputFile.Close()
   404  		if outputFileErr != nil {
   405  			logger.WithFields(logrus.Fields{
   406  				"Error": outputFileErr,
   407  			}).Info("Failed to close output file")
   408  		}
   409  	}
   410  	return []string{consolidatedPath}, nil
   411  }
   412  
   413  // Profile is the interactive command used to pull S3 assets locally into /tmp
   414  // and run ppro against the cached profiles
   415  func Profile(serviceName string,
   416  	serviceDescription string,
   417  	s3BucketName string,
   418  	httpPort int,
   419  	logger *logrus.Logger) error {
   420  
   421  	awsSession := spartaAWS.NewSession(logger)
   422  
   423  	// Get the currently active stacks...
   424  	// Ref: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html#w2ab2c15c15c17c11
   425  	stackSummaries, stackSummariesErr := spartaCF.ListStacks(awsSession, 1024, "CREATE_COMPLETE",
   426  		"UPDATE_COMPLETE",
   427  		"UPDATE_ROLLBACK_COMPLETE")
   428  
   429  	if stackSummariesErr != nil {
   430  		return stackSummariesErr
   431  	}
   432  	// Get the stack names
   433  	stackNameToIDMap := make(map[string]string)
   434  	for _, eachSummary := range stackSummaries {
   435  		stackNameToIDMap[*eachSummary.StackName] = *eachSummary.StackId
   436  	}
   437  	responses, responsesErr := askQuestions(serviceName, stackNameToIDMap)
   438  	if responsesErr != nil {
   439  		return responsesErr
   440  	}
   441  
   442  	// What does the user want to view?
   443  	tempFilePaths, tempFilePathsErr := syncStackProfileSnapshots(responses.ProfileType,
   444  		responses.RefreshSnapshots,
   445  		responses.StackName,
   446  		responses.StackInstance,
   447  		s3BucketName,
   448  		awsSession,
   449  		logger)
   450  	if tempFilePathsErr != nil {
   451  		return tempFilePathsErr
   452  	}
   453  	// We can't hook the PProf webserver, so put some friendly output
   454  	logger.Info(fmt.Sprintf("Starting pprof webserver on http://localhost:%d. Enter Ctrl+C to exit.", httpPort))
   455  
   456  	// Startup a server we manage s.t we can gracefully exit..
   457  	newArgs := []string{os.Args[0]}
   458  	newArgs = append(newArgs, responses.ProfileOptions...)
   459  	newArgs = append(newArgs, "-http", fmt.Sprintf(":%d", httpPort), os.Args[0])
   460  	newArgs = append(newArgs, tempFilePaths...)
   461  	os.Args = newArgs
   462  	return driver.PProf(&driver.Options{})
   463  }
   464  
   465  // ScheduleProfileLoop installs a profiling loop that pushes profile information
   466  // to S3 for local consumption using a `profile` command that wraps
   467  // pprof
   468  func ScheduleProfileLoop(s3BucketArchive interface{},
   469  	snapshotInterval time.Duration,
   470  	cpuProfileDuration time.Duration,
   471  	profileNames ...string) {
   472  
   473  	// When we're building, we want a template decorator that will be called
   474  	// by `provision`. This decorator will be responsible for:
   475  	// ensuring each function has IAM creds (if the role isn't a string)
   476  	// to write to the profile location and also pushing the
   477  	// Stack name info as reseved environment variables into the function
   478  	// execution context so that the AWS lambda version of this function
   479  	// can quickly lookup the StackName and instance information ...
   480  	profileDecorator = func(stackName string, info *LambdaAWSInfo, S3Bucket string, logger *logrus.Logger) error {
   481  		// If we have a role definition, ensure the function has rights to upload
   482  		// to that bucket, with the limited ARN key
   483  		logger.WithFields(logrus.Fields{
   484  			"Function": info.lambdaFunctionName(),
   485  		}).Info("Instrumenting function for profiling")
   486  
   487  		// The bucket is either a literal or a gocf.StringExpr - which one?
   488  		var bucketValue gocf.Stringable
   489  		if s3BucketArchive != nil {
   490  			bucketValue = spartaCF.DynamicValueToStringExpr(s3BucketArchive)
   491  		} else {
   492  			bucketValue = gocf.String(S3Bucket)
   493  		}
   494  
   495  		// 1. Add the env vars to the map
   496  		if info.Options.Environment == nil {
   497  			info.Options.Environment = make(map[string]*gocf.StringExpr)
   498  		}
   499  		info.Options.Environment[envVarStackName] = gocf.Ref("AWS::StackName").String()
   500  		info.Options.Environment[envVarStackInstanceID] = gocf.Ref("AWS::StackId").String()
   501  		info.Options.Environment[envVarProfileBucketName] = bucketValue.String()
   502  
   503  		// Update the IAM role...
   504  		if info.RoleDefinition != nil {
   505  			arn := gocf.Join("",
   506  				gocf.String("arn:aws:s3:::"),
   507  				bucketValue,
   508  				gocf.String("/"),
   509  				gocf.String(profileSnapshotRootKeypath(stackName)),
   510  				gocf.String("/*"))
   511  
   512  			info.RoleDefinition.Privileges = append(info.RoleDefinition.Privileges, IAMRolePrivilege{
   513  				Actions:  []string{"s3:PutObject"},
   514  				Resource: arn.String(),
   515  			})
   516  		}
   517  		return nil
   518  	}
   519  }