├── .dockerignore ├── .github └── workflows │ ├── main.yaml │ └── secrets.yaml ├── .gitignore ├── Dockerfile ├── Dockerfile.empty ├── LICENSE ├── README.md ├── rav.yaml ├── serverless-python.code-workspace ├── src ├── __init__.py ├── entrypoint.sh ├── env.py ├── main.py ├── requirements.txt └── tests.py └── verify-secret.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .env* 3 | Dockerfile 4 | Dockerfile.empty 5 | README.md 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Test, Build, and Push to Google Cloud run 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "main" 8 | - "18-end" 9 | - "19-start" 10 | - "19-end" 11 | - "20-start" 12 | - "20-end" 13 | - "master" 14 | 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v3 22 | - name: Setup Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: "3.8" 26 | - name: Install requirements 27 | run: | 28 | python -m pip install -r src/requirements.txt 29 | python -m pip install pytest 30 | - name: Run tests 31 | env: 32 | MODE: "github actions" 33 | run: | 34 | pytest src/tests.py 35 | build_deploy: 36 | needs: test 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v2 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v2 45 | - id: 'auth' 46 | name: 'Authenticate to Google Cloud' 47 | uses: 'google-github-actions/auth@v1' 48 | with: 49 | credentials_json: '${{ secrets.GOOGLE_CREDENTIALS }}' 50 | - name: Build container image 51 | run: | 52 | docker build -f Dockerfile -t inline-docker-tag . 53 | docker tag inline-docker-tag ${{ secrets.CONTAINER_IMAGE_URL }}:latest 54 | docker tag inline-docker-tag ${{ secrets.CONTAINER_IMAGE_URL }}:${GITHUB_RUN_ID} 55 | gcloud auth configure-docker ${{ secrets.GCLOUD_REGION }}-docker.pkg.dev 56 | docker push ${{ secrets.CONTAINER_IMAGE_URL }} --all-tags 57 | - name: Deploy container to Cloud Run 58 | run: | 59 | gcloud run deploy serverless-py-run \ 60 | --image=${{ secrets.CONTAINER_IMAGE_URL }}:${GITHUB_RUN_ID} \ 61 | --allow-unauthenticated \ 62 | --region=${{ secrets.GCLOUD_REGION }} \ 63 | --project=${{ secrets.GCLOUD_PROJECT_ID }} -------------------------------------------------------------------------------- /.github/workflows/secrets.yaml: -------------------------------------------------------------------------------- 1 | name: Google Cloud Secrets Manager Workflow 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | update_secret: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - id: 'auth' 11 | name: 'Authenticate to Google Cloud' 12 | uses: 'google-github-actions/auth@v1' 13 | with: 14 | credentials_json: '${{ secrets.GOOGLE_CREDENTIALS }}' 15 | - name: Configure dotenv file 16 | run: | 17 | cat << EOF > .env 18 | MODE=${{ secrets.APP_MODE }} 19 | TOKEN=${{ secrets.APP_TOKEN }} 20 | EOF 21 | - name: Run a new version of secrets 22 | run: | 23 | gcloud secrets versions add ${{ secrets.GCLOUD_SECRET_LABEL }} --data-file .env 24 | # - name: Run a new version of secrets 25 | # run: | 26 | # gcloud secrets remove-iam-policy-binding ${{ secrets.GCLOUD_SECRET_LABEL }} --member='serviceAccount:' --role='roles/secretmanager.secretAccessor' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # which version of python 2 | FROM python:3.8.16-slim 3 | 4 | # what code and docs 5 | # COPY local_dir container_dir 6 | # COPY ./src/requirements.txt /app/src/requirements.txt 7 | COPY . /app 8 | WORKDIR /app/ 9 | 10 | # default installs 11 | RUN apt-get update && \ 12 | apt-get install -y \ 13 | build-essential \ 14 | python3-dev \ 15 | python3-setuptools \ 16 | gcc \ 17 | make 18 | 19 | # create a virtualenv 20 | RUN python3 -m venv /opt/venv && \ 21 | /opt/venv/bin/python -m pip install pip --upgrade && \ 22 | /opt/venv/bin/python -m pip install -r /app/src/requirements.txt 23 | 24 | # purge unused 25 | RUN apt-get remove -y --purge make gcc build-essential \ 26 | && apt-get autoremove -y \ 27 | && rm -rf /var/lib/apt/lists/* 28 | 29 | # make entrypoint executable 30 | RUN chmod +x ./src/entrypoint.sh 31 | 32 | # run the app 33 | CMD ["./src/entrypoint.sh"] -------------------------------------------------------------------------------- /Dockerfile.empty: -------------------------------------------------------------------------------- 1 | # which version of python 2 | FROM python:3.8.16-slim 3 | 4 | COPY . /app 5 | WORKDIR /app 6 | 7 | # run the app 8 | CMD ["python", "-m", "http.server"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Coding For Entrepreneurs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Serverless Container Python App](https://static.codingforentrepreneurs.com/media/projects/serverless-container-python-app/images/share/Serverless_Container_on_GCP_-_Share.jpg)](https://www.codingforentrepreneurs.com/projects/serverless-container-python-app/) 2 | 3 | # Serverless Container Python App 4 | 5 | Serverless enables you to focus on code, not infrastructure. Deploy a Docker Container to Cloud Run using this series. Cloud Run is a service by the Google Cloud Platform. 6 | 7 | ## Resources: 8 | - Course [here](https://www.codingforentrepreneurs.com/projects/serverless-container-python-app/). 9 | - Install Gcloud CLI Blog post [here](https://www.codingforentrepreneurs.com/blog/google-cloud-cli-and-sdk-setup/) 10 | 11 | 12 | 13 | This project has been updated. To view the 2020 version of the code go [here](https://github.com/codingforentrepreneurs/Serverless-Container-Based-Python-App-on-Google-Cloud-Run-2020). -------------------------------------------------------------------------------- /rav.yaml: -------------------------------------------------------------------------------- 1 | scripts: 2 | runserver: uvicorn src.main:app --reload 3 | installs: venv/bin/python -m pip install -r src/requirements.txt 4 | test: pytest src/tests.py 5 | build: docker build -f Dockerfile -t serverless-py . 6 | empty-build: docker build -f Dockerfile.empty -t empty-py . 7 | empty-shell: docker exec -it empty_py /bin/bash 8 | empty-run: docker run -p 8001:8000 --rm --name empty_py -it empty-py 9 | container_stage: docker run -e MODE=stage -p 8000:8000 --rm --name serverless-py -it serverless-py 10 | container_prod: docker run -e PORT=8001 -e MODE=PRODA --env-file .env-prod -p 8000:8001 --rm --name serverless-py -it serverless-py 11 | run: docker run --env-file .env-prod -p 8000:8000 --rm --name serverless-py -it serverless-py 12 | update_prod_secrets: 13 | - gcloud secrets versions add py_env_file --data-file .env-prod 14 | build_run: 15 | - rav run build 16 | - rav run run 17 | configure: 18 | - gcloud auth configure-docker us-central1-docker.pkg.dev 19 | - gcloud artifacts repositories create serverless-py-repo --repository-format=docker --location=us-central1 20 | push: 21 | - docker build --platform=linux/amd64 -f Dockerfile -t serverless-py-amd64 . 22 | - docker tag serverless-py-amd64 us-central1-docker.pkg.dev/cfe-serverless-py-2023/serverless-py-repo/serverless-py:latest 23 | - docker tag serverless-py-amd64 us-central1-docker.pkg.dev/cfe-serverless-py-2023/serverless-py-repo/serverless-py:v1 24 | - docker tag serverless-py-amd64 us-central1-docker.pkg.dev/cfe-serverless-py-2023/serverless-py-repo/serverless-py:v1.120 25 | - docker push us-central1-docker.pkg.dev/cfe-serverless-py-2023/serverless-py-repo/serverless-py --all-tags 26 | deploy: 27 | - gcloud run deploy serverless-py-run --image=us-central1-docker.pkg.dev/cfe-serverless-py-2023/serverless-py-repo/serverless-py:latest --allow-unauthenticated --region=us-central1 --project=cfe-serverless-py-2023 28 | cloud_run_perms: 29 | - gcloud secrets add-iam-policy-binding py_env_file --member='serviceAccount:60927173241-compute@developer.gserviceaccount.com' --role='roles/secretmanager.secretAccessor' 30 | cloud_run_perms_remove: 31 | - gcloud secrets remove-iam-policy-binding py_env_file --member='serviceAccount:60927173241-compute@developer.gserviceaccount.com' --role='roles/secretmanager.secretAccessor' -------------------------------------------------------------------------------- /serverless-python.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ] 7 | } -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/Serverless-Container-Based-Python-App-on-Google-Cloud-Run/ed7ef921181e0563b773b9c4905cf5159e39f6c7/src/__init__.py -------------------------------------------------------------------------------- /src/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APP_PORT=${PORT:-8000} 4 | 5 | cd /app/ 6 | /opt/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker src.main:app --bind "0.0.0.0:${APP_PORT}" -------------------------------------------------------------------------------- /src/env.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import pathlib 4 | from functools import lru_cache 5 | from decouple import Config, RepositoryEmpty, RepositoryEnv 6 | # import google-auth 7 | import google.auth 8 | # import google-cloud-secret-manager 9 | from google.cloud import secretmanager 10 | BASE_DIR = pathlib.Path(__file__).parent.parent 11 | print(BASE_DIR) 12 | ENV_PATH = BASE_DIR / ".env" 13 | print(ENV_PATH) 14 | 15 | # print(os.environ.get("GCLOUD_SECRET_LABEL") or "py_env_file") 16 | 17 | def get_google_secret_payload(secret_label = "py_env_file", version="latest"): 18 | payload = None 19 | try: 20 | _, project_id = google.auth.default() 21 | except google.auth.exceptions.DefaultCredentialsError: 22 | project_id = None 23 | if project_id is not None: 24 | client = secretmanager.SecretManagerServiceClient() 25 | # my_secret_file is the name of the secret you 26 | # set with `gcloud secrets create ....` 27 | # project_id comes from previous step 28 | secret_label = os.environ.get("GCLOUD_SECRET_LABEL") or secret_label 29 | version = os.environ.get("GCLOUD_SECRET_VERSION") or version 30 | gcloud_secret_name = f"projects/{project_id}/secrets/{secret_label}/versions/{version}" 31 | # this should print the contents of your secret 32 | payload = client.access_secret_version(name=gcloud_secret_name).payload.data.decode("UTF-8") 33 | return payload 34 | 35 | 36 | class RepositoryString(RepositoryEmpty): 37 | """ 38 | Retrieves option keys from an ENV string file 39 | """ 40 | def __init__(self, source): 41 | """ 42 | Take a string source with the dotenv file format: 43 | 44 | KEY=value 45 | 46 | Then parse it into a dictionary 47 | """ 48 | source = io.StringIO(source) 49 | if not isinstance(source, io.StringIO): 50 | raise ValueError("source must be an instance of io.StringIO") 51 | self.data = {} 52 | file_ = source.read().split("\n") 53 | for line in file_: 54 | line = line.strip() 55 | if not line or line.startswith("#") or "=" not in line: 56 | continue 57 | k, v = line.split("=", 1) 58 | k = k.strip() 59 | v = v.strip() 60 | if len(v) >= 2 and ( 61 | (v[0] == "'" and v[-1] == "'") or (v[0] == '"' and v[-1] == '"') 62 | ): 63 | v = v[1:-1] 64 | self.data[k] = v 65 | 66 | def __contains__(self, key): 67 | return key in os.environ or key in self.data 68 | 69 | def __getitem__(self, key): 70 | return self.data[key] 71 | 72 | 73 | 74 | @lru_cache() 75 | def get_config(use_gcloud=True): 76 | if ENV_PATH.exists(): 77 | return Config(RepositoryEnv(str(ENV_PATH))) 78 | if use_gcloud: 79 | payload = get_google_secret_payload() 80 | if payload is not None: 81 | return Config(RepositoryString(payload)) 82 | from decouple import config 83 | return config 84 | 85 | config = get_config() -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from fastapi import FastAPI 3 | from src.env import config 4 | 5 | MODE=config("MODE", cast=str, default="test") 6 | 7 | app = FastAPI() 8 | 9 | @app.get("/") # GET -> HTTP METHOD 10 | def home_page(): 11 | # for API services 12 | # JSON-ready dict -> json.dumps({'hello': 'world'}) 13 | return {"Hello": "World", "mode": MODE} 14 | 15 | # @app.post("/") # POST -> HTTP METHOD 16 | # def home_handle_data_page(): 17 | # # for API services 18 | # # JSON-ready dict -> json.dumps({'hello': 'world'}) 19 | # return {"Hello": "World"} -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | FastAPI 2 | gunicorn 3 | uvicorn 4 | python-decouple 5 | pytest 6 | httpx 7 | google-auth 8 | google-cloud-secret-manager -------------------------------------------------------------------------------- /src/tests.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from src.main import app 3 | 4 | 5 | client = TestClient(app) 6 | 7 | def test_get_home_status(): 8 | path = "/" 9 | # python requests 10 | # r = requests.get(path) 11 | response = client.get(path) 12 | status_code = response.status_code 13 | content_type = response.headers['content-type'] 14 | assert status_code == 200 # HTTP response 15 | assert content_type == "application/json" 16 | -------------------------------------------------------------------------------- /verify-secret.py: -------------------------------------------------------------------------------- 1 | # import google-auth 2 | import google.auth 3 | try: 4 | _, project_id = google.auth.default() 5 | except google.auth.exceptions.DefaultCredentialsError: 6 | project_id = None 7 | print(project_id) 8 | 9 | 10 | if project_id is not None: 11 | # import google-cloud-secret-manager 12 | from google.cloud import secretmanager 13 | 14 | 15 | client = secretmanager.SecretManagerServiceClient() 16 | # my_secret_file is the name of the secret you 17 | # set with `gcloud secrets create ....` 18 | secret_label = "py_env_file" 19 | 20 | # project_id comes from previous step 21 | gcloud_secret_name = f"projects/{project_id}/secrets/{secret_label}/versions/latest" 22 | 23 | # this should print the contents of your secret 24 | payload = client.access_secret_version(name=gcloud_secret_name).payload.data.decode("UTF-8") 25 | 26 | # print the contents 27 | print(payload) 28 | print(payload.get("MODE")) 29 | --------------------------------------------------------------------------------