Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable audit to write cache to disk to reduce memory #1634

Merged
merged 6 commits into from
Nov 10, 2021

Conversation

ritazh
Copy link
Member

@ritazh ritazh commented Nov 2, 2021

Signed-off-by: Rita Zhang rita.z.zhang@gmail.com

What this PR does / why we need it:
Improve audit memory footprint by writing audit cache to disk

Which issue(s) this PR fixes (optional, using fixes #<issue number>(, fixes #<issue_number>, ...) format, will close the issue(s) when the PR gets merged):
Fixes #163
Fixes #1088
Fixes #1279
Partial #1405

Special notes for your reviewer:

@codecov-commenter
Copy link

codecov-commenter commented Nov 2, 2021

Codecov Report

Merging #1634 (40a7480) into master (87cb662) will decrease coverage by 0.66%.
The diff coverage is 0.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1634      +/-   ##
==========================================
- Coverage   52.81%   52.14%   -0.67%     
==========================================
  Files          98       98              
  Lines        8591     8693     +102     
==========================================
- Hits         4537     4533       -4     
- Misses       3695     3800     +105     
- Partials      359      360       +1     
Flag Coverage Δ
unittests 52.14% <0.00%> (-0.67%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
pkg/audit/manager.go 0.00% <0.00%> (ø)
...onstrainttemplate/constrainttemplate_controller.go 58.76% <0.00%> (-0.95%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 87cb662...40a7480. Read the comment docs.

config/manager/manager.yaml Show resolved Hide resolved
pkg/audit/manager.go Outdated Show resolved Hide resolved
}
resp, err := am.opa.Review(ctx, augmentedObj)
// for each item, write object to a file along with the namespace
fileName := fmt.Sprintf("%s_%d", objNamespace, index)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What purpose does embedding the namespace in the filename serve?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From few lines above, since we are already calling the api server to get the namespace of the object to see if we can skip it to avoid having to create the file if namespace is excluded, we should persist the namespace as part of the filename so we do not have to make another api call later when loading the obj from disk.

objNamespace := objList.Items[index].GetNamespace()
isExcludedNamespace, err := am.skipExcludedNamespace(&objList.Items[index])

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this benefit:

  1. We need to get the full namespace object so that we can evaluate namespace selectors, not the name
  2. We are already caching the namespace in nsCache

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From few lines below, the namespace saved as part of the file name is used to lookup the full namespace object from nsCache:

objNs := strings.Split(fileName, "_")[0]
ns := corev1.Namespace{}
if objNs != "" {
  ns, err = nsCache.Get(ctx, am.client, objNs)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we encapsulate the encoding and decoding logic for these filenames into helper functions, making this naming convention more explicit?

Copy link
Member Author

@ritazh ritazh Nov 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm removing the namespace from the filename but prefixing with kind to avoid delays in os.RemoveAll.

@@ -403,13 +414,71 @@ func (am *Manager) auditResources(
}
}
}
// loop thru each subDir in output dir to get files
for i := 0; i < folderCount; i++ {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it looks like we are auditing after we've looped through all GVKs, we'd definitely need to write out objects to separate directories by GVK in order to avoid clobbering.

One note about how we could be gentler on the API server...

If we perform the audit for a given kind before grabbing the next kind from the API server, we even out the load on the API server somewhat.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per above comment, I dont think we are clobbering it.

If we perform the audit for a given kind before grabbing the next kind from the API server, we even out the load on the API server somewhat.

Can this optimization be a follow up PR given that we would need to do few rounds of load tests?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're not clobbering it.

One other benefit of interleaving audits and lists... you can wipe the disk after each audit, lowering the maximum amount of disk space used.

Why would we need load tests?

In any case, we can defer, but the reduction in disk space seems valuable (akin to how much memory we got back when we switched to auditing on a per-kind basis)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latest commit audits right after cache to disk for a given kind

}
for _, f := range files {
fileName := f.Name()
objNs := strings.Split(fileName, "_")[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is why we are embedding the namespace in the filename, could we retrieve the namespace after we've called am.readUnstructured() and get it from the object itself?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per above comment, we are already making the api call to get namespace to test ns exclusion prior to saving the file. It makes more sense to me to save it as part of the file at that point instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Except we are calling nsCache.Get() here anyway? Per above, we'd need to get the whole namespace object so we can evaluate the namespace selector.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

@maxsmythe maxsmythe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly nits

pkg/audit/manager.go Outdated Show resolved Hide resolved
pkg/audit/manager.go Outdated Show resolved Hide resolved
}
resp, err := am.opa.Review(ctx, augmentedObj)
// for each item, write object to a file along with the namespace
fileName := fmt.Sprintf("%s_%d", objNamespace, index)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this benefit:

  1. We need to get the full namespace object so that we can evaluate namespace selectors, not the name
  2. We are already caching the namespace in nsCache

@@ -403,13 +414,71 @@ func (am *Manager) auditResources(
}
}
}
// loop thru each subDir in output dir to get files
for i := 0; i < folderCount; i++ {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're not clobbering it.

One other benefit of interleaving audits and lists... you can wipe the disk after each audit, lowering the maximum amount of disk space used.

Why would we need load tests?

In any case, we can defer, but the reduction in disk space seems valuable (akin to how much memory we got back when we switched to auditing on a per-kind basis)

}
for _, f := range files {
fileName := f.Name()
objNs := strings.Split(fileName, "_")[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Except we are calling nsCache.Get() here anyway? Per above, we'd need to get the whole namespace object so we can evaluate the namespace selector.

Copy link
Contributor

@shomron shomron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ritazh overall looks good! I had a few questions and nits, nothing blocking. LMKWYT.
I think a cool future enhancement might be to process the reviews concurrently to filling the cache - this might improve overall audit time for larger clusters.

pkg/audit/manager.go Outdated Show resolved Hide resolved
@@ -239,6 +243,15 @@ func (am *Manager) auditResources(
totalViolationsPerConstraint map[util.KindVersionResource]int64,
totalViolationsPerEnforcementAction map[util.EnforcementAction]int64,
timestamp string) error {
// delete all from cache dir before starting audit
dir, err := os.ReadDir(*outputDir)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Do we know how many files will be in this directory? Is it better to page through the items using File.Readdirnames(n) instead of making assumptions, or do we know this will be manageable?
  2. Is there any validation we want to do to make sure it's actually our cache directory, and not / or some other unfortunate mistake?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know how many files will be in this directory?

latest commit adds paging to get files

Is there any validation we want to do to make sure it's actually our cache directory

we pass in apiCacheDir to these functions.

apiCacheDir               = flag.String("api-cache-dir", defaultApiCacheDir, "The directory where  

pkg/audit/manager.go Outdated Show resolved Hide resolved
}
resp, err := am.opa.Review(ctx, augmentedObj)
// for each item, write object to a file along with the namespace
fileName := fmt.Sprintf("%s_%d", objNamespace, index)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we encapsulate the encoding and decoding logic for these filenames into helper functions, making this naming convention more explicit?

pkg/audit/manager.go Outdated Show resolved Hide resolved
pkg/audit/manager.go Outdated Show resolved Hide resolved
pkg/audit/manager.go Show resolved Hide resolved
pkg/audit/manager.go Outdated Show resolved Hide resolved
Signed-off-by: Rita Zhang <rita.z.zhang@gmail.com>
Signed-off-by: Rita Zhang <rita.z.zhang@gmail.com>
Signed-off-by: Rita Zhang <rita.z.zhang@gmail.com>
@@ -12,7 +12,7 @@ enableDeleteOperations: false
enableExternalData: false
mutatingWebhookFailurePolicy: Ignore
mutatingWebhookTimeoutSeconds: 3
auditChunkSize: 0
auditChunkSize: 500
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: updating default auditChunkSize value

@@ -43,16 +46,18 @@ const (
msgSize = 256
defaultAuditInterval = 60
defaultConstraintViolationsLimit = 20
defaultListLimit = 0
defaultListLimit = 500
Copy link
Member Author

@ritazh ritazh Nov 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: updated default to 500 and flag usage. Will open a separate PR to update doc after the next release is out.

Signed-off-by: Rita Zhang <rita.z.zhang@gmail.com>
@ritazh
Copy link
Member Author

ritazh commented Nov 8, 2021

@maxsmythe @shomron I have addressed you comments, PTAL.

defer dir.Close()
for {
names, err := dir.Readdirnames(batchSize)
if err == io.EOF || len(names) == 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we ignore non-EOF errors here. Should we bubble those back up to the caller?

Namespace: &ns,
}
resp, err := am.opa.Review(ctx, augmentedObj)
// for each item, write object to a file along with the namespace
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment still accurate? Does it just mean that the namespace is stored within the file payload?

pkg/audit/manager.go Show resolved Hide resolved
@@ -335,6 +347,14 @@ func (am *Manager) auditResources(
for gv, gvKinds := range clusterAPIResources {
kindsLoop:
for kind := range gvKinds {
// delete all existing folders from cache dir before starting next kind
err := am.removeAllFromDir(*apiCacheDir, int(*auditChunkSize))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did this move into the loop? Are we trying to reduce peak disk usage?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so?

Could be useful if there are a lot of large lists of kinds.


func (am *Manager) reviewObjects(ctx context.Context, kind string, folderCount int, nsCache *nsCache,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a comment somewhere with the cache directory structure? Otherwise we need to infer this from the code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was commented earlier when we create the sub folders.

// for each batch, create a parent folder
// prefix kind to avoid delays in removeall
subPath = fmt.Sprintf("%s_%d", kind, folderCount)
parentDir := path.Join(*apiCacheDir, subPath)

I will add the same comment in this func as well.

subDir := fmt.Sprintf("%s_%d", kind, i)
pDir := path.Join(*apiCacheDir, subDir)

files, err := am.getFilesFromDir(pDir, int(*auditChunkSize))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this pages files from the OS, it still aggregates the list in memory before processing. This should be fine unless we expect the list to be very large. Otherwise we could pass in a processing function to be called per page.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe walk the directory and call audit on every file we walk into?

walk() is probably preferable, but I don't have an issue with this as-is, since the list of file names is probably much smaller than the list of actual objects.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the list of file names is pretty small compare to everything else (actual resources) we are caching. We can further optimize this if we see a dramatic improvement later.

@@ -335,6 +347,14 @@ func (am *Manager) auditResources(
for gv, gvKinds := range clusterAPIResources {
kindsLoop:
for kind := range gvKinds {
// delete all existing folders from cache dir before starting next kind
err := am.removeAllFromDir(*apiCacheDir, int(*auditChunkSize))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so?

Could be useful if there are a lot of large lists of kinds.

subDir := fmt.Sprintf("%s_%d", kind, i)
pDir := path.Join(*apiCacheDir, subDir)

files, err := am.getFilesFromDir(pDir, int(*auditChunkSize))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe walk the directory and call audit on every file we walk into?

walk() is probably preferable, but I don't have an issue with this as-is, since the list of file names is probably much smaller than the list of actual objects.

Signed-off-by: Rita Zhang <rita.z.zhang@gmail.com>
Copy link
Member

@sozercan sozercan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ritazh ritazh merged commit 3442bc9 into open-policy-agent:master Nov 10, 2021
@ritazh ritazh deleted the fix-audit-memory branch November 10, 2021 06:04
@anlandu
Copy link
Member

anlandu commented Jun 17, 2023

QQ, I might be misreading but it looks like this only excludes from writing to disk objects in namespaces that are excluded by ProcessExcluder, which would just be the GK config-level excludedNamespaces? Could the map of matchedKinds also include a field for matchedNamespaces so as to also exclude those that don't match the namespaces defined in the constraint? And potentially use that info to winnow down the List api call?

@maxsmythe
Copy link
Contributor

In theory, but that would presume that:

  • It's common to ignore namespaces
  • The namespaces that are ignored contain a significant chunk of the resources of the cluster

A second point: performance "optimizations" that lower resource coverage are less optimizations and more an acknowledgement of the limits of how far a system can scale. If I can only handle 1000 resources, and remove some from scope, the system can still only handle 1000 resources and I will still have a problem if I scale beyond that size WRT resources that are in scope.

As such, performance improvements, graceful degradation, and removal of upper bounds (e.g. via chunking) should be the main focus, with culling more useful for triage and avoiding unnecessary processing time (as opposed to being an operational necessity).

@anlandu
Copy link
Member

anlandu commented Jun 21, 2023

Thanks for the reply, that makes a lot of sense! I was looking at a case where a cluster had hundreds of thousands of a certain resource type, but none in the default namespace, and were applying the "shouldn't use the default namespace" template with "default" as the only included namespace on the constraint. Obviously a very niche case but it just spurred me to think about possible triages and their feasibility. Totally understand if it's not as worthwhile as actual optimizations!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
6 participants