When packaging any app in a Docker image, you run into the issue of how to protect the secrets, like database credentials or API keys.
We all know about not checking in secrets to Github (don’t we?), but we also don’t want to package secrets in the image. Anyone who can get at the image can also get at the secrets, and since the image proliferates through CI/CD pipeline and image repo, it’s near impossible to limit access effectively.
What we found to work best is to store secrets in a well protected Cloud Storage bucket, and have the app download the secrets on startup. This decouples the secrets from Docker images, and rotating secrets becomes a cinch.
The example here from an app written to run on Google Cloud Platform.
Layering Secrets in Flask
Flask gives you multiple ways to load configuration. Every time it loads a configuration, it overlays the existing configuration.
We can use the default settings (i.e., dev settings or reasonable defaults) in the code, then download and merge secrets.
from my_lib import secrets
app = Flask(__name__)
# Load default config
app.config.from_pyfile('config/default_settings.py')
# Overlay secure secrets
secrets.load(app)
Loading Secrets from Cloud Storage
Here we download secrets from a Cloud Storage bucket. The bucket should be restricted to allow read access from a specific service account assigned to the app.
import os
import uuid
from google.cloud import storage, kms_v1
def load(app):
config_file_name = "secrets-%s.cfg" % str(uuid.uuid4())
storage_client = storage.Client()
bucket = storage_client.get_bucket('vault')
blob = bucket.blob('secrets.cfg')
blob.download_to_filename(config_file_name)
try:
app.config.from_pyfile(config_file_name)
finally:
os.remove(config_file_name)
Extra Paranoid Secrets Loading
As a paranoid implementation, you can encrypt the secrets in the bucket. The app decrypts the downloaded secrets then loads it in. Both encrypted and decrypted files get deleted. The app needs permission for the bucket as well as the crypto keyring in KMS.
def load_from_encrypted(app):
tempname = str(uuid.uuid4())
encrypted_file_name = "secrets-%s.cfg.encrypted" % tempname
decrypted_file_name = "secrets-%s.cfg.decrypted" % tempname
storage_client = storage.Client()
bucket = storage_client.get_bucket('vault')
blob = bucket.blob('secrets.cfg.encrypted')
blob.download_to_filename(encrypted_file_name)
client = kms_v1.KeyManagementServiceClient()
key_name = client.crypto_key_path('app', 'global', 'keyring', 'secrets')
try:
with open(encrypted_file_name, 'rb') as content_file:
cipher_text = content_file.read()
decrypted = client.decrypt(key_name, cipher_text)
with open(decrypted_file_name, 'w') as decrypted_file:
decrypted_file.write(\
decrypted.plaintext.decode("utf-8"))
app.config.from_pyfile(decrypted_file_name)
finally:
os.remove(encrypted_file_name)
os.remove(decrypted_file_name)
Encrypting Secrets
You need to encrypt the secret file before uploading to the bucket. The shell script below encrypts and uploads a secrets.cfg
file. To run this script, you need access to KMS keyring as well as write permission for the vault
bucket
#!/usr/bin/env bash
gcloud services enable cloudkms.googleapis.com
KEYRING=keyring
KEY=secrets
gcloud kms keys list --location global --keyring $KEYRING
if [[ $? -ne 0 ]]; then
gcloud kms keyrings create ${KEYRING} --location global
gcloud kms keys create ${KEY} --location global \
--keyring ${KEYRING} --purpose encryption
fi
rm -f secrets.cfg.encrypted
gcloud kms encrypt --location global \
--keyring ${KEYRING} \
--key ${KEY} \
--plaintext-file secrets.cfg \
--ciphertext-file secrets.cfg.encrypted
gsutil cp sectets.cfg.encrypted gs://vault
Flask + Gunicorn Caveat
When running a Flask app under gunicorn, loading configuration file using relative path (i.e. app.config.from_pyfile('config/default_settings.py')
) can fail. This is because gunicorn doesn’t change to the app directory. You need to supply--chdir
flag to make gunicorn change to the directory that app expects.
Dockerfile example:
ENV APP_HOME /app
ENV PORT 8080
CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --chdir $APP_HOME --threads 1 app:app