-
Notifications
You must be signed in to change notification settings - Fork 178
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
[Feature] Improve key-pair auth performance #1082
[Feature] Improve key-pair auth performance #1082
Comments
Useful docs I used for testing: https://docs.snowflake.com/en/user-guide/key-pair-auth openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out rsa_key.p8 -nocrypt
openssl rsa -in rsa_key.p8 -pubout -out rsa_key.pub ALTER USER <you> SET RSA_PUBLIC_KEY=''; -- yes this will require admin help; Don't include header/footer strings |
Hey team! I found this issue after switching from a 2048-bit key-pair to a 4096-bit one, and found my dbt run times increasing from ~5 minutes to ~15 minutes. I had a bit of a dig on this, and figured I'd share some findings (and can put together some suggestions for changes as well if there's a preference on how this is handled in the project). The core issue seems to be that the private key is being read (and validated - I'll get to that) on every dbt node, which eats up the time. First - this took me too long to find, but the easiest solution seems to just be using the reuse_connections profile config. Maybe it could be a suggestion in the key-pair authentication section of the docs to use that config? Anyway - in terms of testing what was going on, I did a few tests using the Snowflake Connector for Python and found that (with a fixed private key) execution times were virtually the same across either password, 2048-bit key, or 4096-bit key options. I had a look at I was a bit surprised by this, so I decided to check how long loading keys actually took. My test script looked like this: def benchmark_key_import(key: str, unsafe_skip_rsa_key_validation: bool = False, n_tries: int = 1000):
start = time.time()
for _ in range(0, n_tries):
private_key = serialization.load_pem_private_key(
data=bytes(key, 'utf-8'),
password=None,
unsafe_skip_rsa_key_validation=unsafe_skip_rsa_key_validation
)
key_bytes = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
end = time.time()
print(end - start) Some results from that:
That validation is pretty substantial, which is why I didn't want to immediately put a PR together. The cryptography docs don't provide much detail on exactly what's unsafe about skipping validation, and I don't know nearly enough about the security elements to say for sure. However, the Snowflake Connector for Python is also using cryptography, and either requires bytes (which it reads with validation) or an instance of RSAPrivateKey (which would already be validated). Essentially, this means that dbt-snowflake can (and perhaps should) skip validation since it's already being done later by the Snowflake Connector and there's no value in doing it twice. Caching would of course help as well; I imagine there aren't any cases where data changes during an execution such that a cached result became invalid (and if it did, then it could probably be stored in a dictionary instead to maintain it) but my sense based on the above is that skipping validation would be a more sensible solution and would effectively invalidate the need for caching. Beyond that, and as mentioned at the top, I think enabling the |
Thanks for the thorough analysis @amardatar! I agree that updating the default behavior of |
Hi @amardatar, thanks for the thorough analysis; it is much appreciated! The ProblemWhat you've stumbled into looks like a known issue in
The description from the PR that implemented the
Given that this keypair is user-configurable from YAML, I don't feel comfortable bypassing this check. A Potential Solution
This is a great observation, so I took the liberty of modifying your script into a # benchmark.py
import time
from cryptography.hazmat.primitives import serialization
from functools import cache
@cache
def cached_load_pem_private_key(data, password, unsafe_skip_rsa_key_validation):
return serialization.load_pem_private_key(
data=data,
password=password,
unsafe_skip_rsa_key_validation=unsafe_skip_rsa_key_validation,
)
def benchmark_key_import(
key: str,
unsafe_skip_rsa_key_validation: bool = False,
n_tries: int = 1000,
method=serialization.load_pem_private_key,
):
print(
f"unsafe_skip_rsa_key_validation={unsafe_skip_rsa_key_validation}, n_tries={n_tries}, method={method}"
)
cached_load_pem_private_key.cache_clear()
start = time.time()
for _ in range(0, n_tries):
private_key = method(
data=bytes(key, "utf-8"),
password=None,
unsafe_skip_rsa_key_validation=unsafe_skip_rsa_key_validation,
)
key_bytes = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
end = time.time()
print(end - start)
print(cached_load_pem_private_key.cache_info())
print()
with open("key.pem", "r") as f:
key = f.read()
benchmark_key_import(key)
benchmark_key_import(key, unsafe_skip_rsa_key_validation=True)
benchmark_key_import(key, method=cached_load_pem_private_key)
benchmark_key_import(
key, method=cached_load_pem_private_key, unsafe_skip_rsa_key_validation=True
) ❯ openssl genrsa -out key.pem 4096
❯ python benchmark.py
unsafe_skip_rsa_key_validation=False, n_tries=1000, method=<built-in function load_pem_private_key>
345.70546793937683
CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
unsafe_skip_rsa_key_validation=True, n_tries=1000, method=<built-in function load_pem_private_key>
0.16813015937805176
CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
unsafe_skip_rsa_key_validation=False, n_tries=1000, method=<functools._lru_cache_wrapper object at 0x102551170>
0.4363410472869873
CacheInfo(hits=999, misses=1, maxsize=None, currsize=1)
unsafe_skip_rsa_key_validation=True, n_tries=1000, method=<functools._lru_cache_wrapper object at 0x102551170>
0.09387397766113281
CacheInfo(hits=999, misses=1, maxsize=None, currsize=1) From the results above, we can get most of the runtime improvement from caching Thanks again for starting the conversation @amardatar, would love to hear your perspective. |
Hey @aranke - no worries, and thanks for the context - I didn't know that, but makes sense! So my suggestion on using In terms of caching - I did try a very basic implementation in dbt-snowflake, and didn't leave enough info in the comment above, but it's probably useful so I'll just run through it here. Just summarising the below:
In more detail: My first attempt with caching was just a straight-forward cache of the result of I tried instead returning and using the response of I don't know enough about how this is all being used to know if it's okay to avoid serialising the key in some way - if that can be done, then I think the performance benefits will be realised since the key won't need to be re-validated (by dbt-snowflake or by the Snowflake Connector). Without doing that, caching the Maybe another option though would be to skip validation after the first time (and return an instance of |
Opened a new issue in dbt-labs/docs.getdbt.com: dbt-labs/docs.getdbt.com#5779 |
## What are you changing in this pull request and why? The default for `reuse_connections` has been [updated](dbt-labs/dbt-snowflake#1082) for `dbt-snowflake`. This change will affect `versionless` and will be released as part of `v1.9`. ## Checklist <!-- Uncomment when publishing docs for a prerelease version of dbt: - [ ] Add versioning components, as described in [Versioning Docs](https://github.com/dbt-labs/docs.getdbt.com/blob/current/contributing/single-sourcing-content.md#versioning-entire-pages) - [ ] Add a note to the prerelease version [Migration Guide](https://github.com/dbt-labs/docs.getdbt.com/tree/current/website/docs/docs/dbt-versions/core-upgrade) --> - [x] Review the [Content style guide](https://github.com/dbt-labs/docs.getdbt.com/blob/current/contributing/content-style-guide.md) so my content adheres to these guidelines. - [x] For [docs versioning](https://github.com/dbt-labs/docs.getdbt.com/blob/current/contributing/single-sourcing-content.md#about-versioning), review how to [version a whole page](https://github.com/dbt-labs/docs.getdbt.com/blob/current/contributing/single-sourcing-content.md#adding-a-new-version) and [version a block of content](https://github.com/dbt-labs/docs.getdbt.com/blob/current/contributing/single-sourcing-content.md#versioning-blocks-of-content). - [x] Add a checklist item for anything that needs to happen before this PR is merged, such as "needs technical review" or "change base branch." --------- Co-authored-by: Matt Shaver <[email protected]>
Is this your first time submitting a feature request?
Describe the feature
@VersusFacit's analysis:
400 vs 1000 is quite a dramatic difference!
Avenues for investigation:
The text was updated successfully, but these errors were encountered: