From 9e1a8e2c2d78b5f5c30c59f5e193e5516296b2e5 Mon Sep 17 00:00:00 2001 From: Yongrae Jo Date: Sat, 10 Oct 2020 15:49:17 +0900 Subject: [PATCH 1/2] feat: Deploying --- .github/workflows/deploy.yaml | 133 +++++++++++++++++++++++ configs/deploying/latest.yaml | 7 ++ deploying/helm/Chart.yaml | 21 ++++ deploying/helm/templates/deployment.yaml | 34 ++++++ deploying/helm/templates/service.yaml | 14 +++ deploying/helm/values.yaml | 66 +++++++++++ predictor.py | 3 + requirements.txt | 4 +- server.Dockerfile | 18 +++ server.py | 25 +++++ serving/app_factory.py | 19 ++++ 11 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yaml create mode 100644 configs/deploying/latest.yaml create mode 100644 deploying/helm/Chart.yaml create mode 100644 deploying/helm/templates/deployment.yaml create mode 100644 deploying/helm/templates/service.yaml create mode 100644 deploying/helm/values.yaml create mode 100644 predictor.py create mode 100644 server.Dockerfile create mode 100644 server.py create mode 100644 serving/app_factory.py diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..46aab99 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,133 @@ +name: Build, Publish, and Deploy + +on: + push: + branches: + - master + +env: + IMAGE_TAG: ${{ github.sha }} + GKE_CLUSTER: monthly-deeplearning-cluster + GKE_ZONE: us-west1-a + +jobs: + build-publish-deploy: + name: Build, Publish, and Deploy + runs-on: ubuntu-latest + + steps: + - name: Extract branch name + id: extract_branch + shell: bash + run: | + branch_name=${GITHUB_REF#refs/heads/} + if [ $branch_name = "master" ] + then + env_name=latest; + else + env_name=${branch_name#env/}; + fi + echo "##[set-output name=env_name;]$env_name" + + - name: Checkout + uses: actions/checkout@v2 + + - name: Extract deploying config + id: extract_config + env: + CONFIG_PATH: configs/deploying/${{ steps.extract_branch.outputs.env_name }}.yaml + run: | + # Convert yaml to json + config=`python -c 'import sys, yaml, json; json.dump(yaml.load(sys.stdin), sys.stdout, indent=4)' < $CONFIG_PATH` + + username=`echo $(jq -r '.username' <<< "$config")` + registry=`echo $(jq -r '.registry' <<< "$config")` + owner=`echo $(jq -r '.owner' <<< "$config")` + repository=`echo $(jq -r '.repository' <<< "$config")` + image_name=`echo $(jq -r '.image_name' <<< "$config")` + helm_release_name=`echo $(jq -r '.helm_release_name' <<< "$config")` + + echo "##[set-output name=username;]$username" + echo "##[set-output name=registry;]$registry" + echo "##[set-output name=owner;]$owner" + echo "##[set-output name=repository;]$repository" + echo "##[set-output name=image_name;]$image_name" + echo "##[set-output name=helm_release_name;]$helm_release_name" + + - name: Login to Github Package + env: + REGISTRY: ${{ steps.extract_config.outputs.registry }} + USERNAME: ${{ steps.extract_config.outputs.username }} + PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: | + docker login $REGISTRY -u $USERNAME --password $PASSWORD + + - name: Build Image + id: build-image + env: + ENVIRONMENT: ${{ steps.extract_branch.outputs.env_name }} + REGISTRY: ${{ steps.extract_config.outputs.registry }} + OWNER: ${{ steps.extract_config.outputs.owner }} + REPOSITORY: ${{ steps.extract_config.outputs.repository }} + IMAGE_NAME: ${{ steps.extract_config.outputs.image_name }} + run: | + docker pull $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$ENVIRONMENT || true + docker build . --file charlm-server.Dockerfile --tag $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$IMAGE_TAG \ + --cache-from $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$ENVIRONMENT + echo "::set-output name=image_name::$REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$IMAGE_TAG" + + - name: Test Image + id: test-image + env: + ENVIRONMENT: ${{ steps.extract_branch.outputs.env_name }} + REGISTRY: ${{ steps.extract_config.outputs.registry }} + OWNER: ${{ steps.extract_config.outputs.owner }} + REPOSITORY: ${{ steps.extract_config.outputs.repository }} + IMAGE_NAME: ${{ steps.extract_config.outputs.image_name }} + run: | + CONTAINER_ID=$(docker run -d --rm --env ENVIRONMENT $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$IMAGE_TAG) + docker exec $CONTAINER_ID pytest + + - name: Publish + env: + ENVIRONMENT: ${{ steps.extract_branch.outputs.env_name }} + REGISTRY: ${{ steps.extract_config.outputs.registry }} + OWNER: ${{ steps.extract_config.outputs.owner }} + REPOSITORY: ${{ steps.extract_config.outputs.repository }} + IMAGE_NAME: ${{ steps.extract_config.outputs.image_name }} + run: | + docker push $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$IMAGE_TAG + # For caching + docker tag $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$IMAGE_TAG $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$ENVIRONMENT + docker push $REGISTRY/$OWNER/$REPOSITORY/$IMAGE_NAME:$ENVIRONMENT + + - name: Setup gcloud + uses: GoogleCloudPlatform/github-actions/setup-gcloud@master + with: + version: '290.0.1' + service_account_key: ${{ secrets.GKE_SA_KEY }} + project_id: monthly-deeplearning + + - name: Configure docker to use the gcloud command-line tool as a credential helper for authentication + run: | + gcloud --quiet auth configure-docker + + - name: Get the GKE credentials so we can deploy to the cluster + run: | + gcloud container clusters get-credentials "$GKE_CLUSTER" --zone "$GKE_ZONE" + + - name: Install Helm + run: | + curl https://helm.baltorepo.com/organization/signing.asc | sudo apt-key add - + sudo apt-get install apt-transport-https --yes + echo "deb https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list + sudo apt-get update + sudo apt-get install helm + + - name: Deploy Helm + env: + ENVIRONMENT: ${{ steps.extract_branch.outputs.env_name }} + HELM_RELEASE_NAME: ${{ steps.extract_config.outputs.helm_release_name }} + run: | + helm upgrade $HELM_RELEASE_NAME ./deploying/helm --install --wait --atomic --namespace=$ENVIRONMENT \ + --set=environment=$ENVIRONMENT --set=image_tag=$IMAGE_TAG --values=configs/deploying/$ENVIRONMENT.yaml diff --git a/configs/deploying/latest.yaml b/configs/deploying/latest.yaml new file mode 100644 index 0000000..0d5e117 --- /dev/null +++ b/configs/deploying/latest.yaml @@ -0,0 +1,7 @@ +username: dreamgonfly +registry: docker.pkg.github.com +owner: hephaestusproject +repository: template +image_name: mnist-server +app_label: mnist +helm_release_name: mnist \ No newline at end of file diff --git a/deploying/helm/Chart.yaml b/deploying/helm/Chart.yaml new file mode 100644 index 0000000..26bebf7 --- /dev/null +++ b/deploying/helm/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: helm-chart +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. +appVersion: 0.1.0 \ No newline at end of file diff --git a/deploying/helm/templates/deployment.yaml b/deploying/helm/templates/deployment.yaml new file mode 100644 index 0000000..9e391af --- /dev/null +++ b/deploying/helm/templates/deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.app_label }}-deployment + labels: + app: {{ .Values.app_label }} + environment: {{ .Values.environment }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Values.app_label }} + template: + metadata: + labels: + app: {{ .Values.app_label }} + spec: + containers: + - name: pg + image: "{{ .Values.registry }}/{{ .Values.owner }}/{{ .Values.repository }}/{{ .Values.image_name }}:{{ .Values.image_tag }}" + env: + - name: ENVIRONMENT + value: {{ .Values.environment }} + ports: + - containerPort: 8000 + protocol: TCP + readinessProbe: + httpGet: + path: /hello + port: 8000 + initialDelaySeconds: 3 + periodSeconds: 15 + imagePullSecrets: + - name: regcred \ No newline at end of file diff --git a/deploying/helm/templates/service.yaml b/deploying/helm/templates/service.yaml new file mode 100644 index 0000000..5f9d057 --- /dev/null +++ b/deploying/helm/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.app_label }}-service + labels: + app: {{ .Values.app_label }} +spec: + type: NodePort + ports: + - port: 8000 + targetPort: 8000 + protocol: TCP + selector: + app: {{ .Values.app_label }} diff --git a/deploying/helm/values.yaml b/deploying/helm/values.yaml new file mode 100644 index 0000000..7f9a6f4 --- /dev/null +++ b/deploying/helm/values.yaml @@ -0,0 +1,66 @@ +# Default values. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} \ No newline at end of file diff --git a/predictor.py b/predictor.py new file mode 100644 index 0000000..3f4c8d9 --- /dev/null +++ b/predictor.py @@ -0,0 +1,3 @@ +class Predictor: + def predict(self, x): + return x diff --git a/requirements.txt b/requirements.txt index ad4c783..c2c62b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ torch==1.5.0 torchvision==0.6.0 -pytorch-lightning==0.8.5 \ No newline at end of file +pytorch-lightning==0.8.5 +fastapi==0.60.1 +uvicorn==0.11.8 \ No newline at end of file diff --git a/server.Dockerfile b/server.Dockerfile new file mode 100644 index 0000000..51ea291 --- /dev/null +++ b/server.Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.7-stretch@sha256:ba2b519dbdacc440dd66a797d3dfcfda6b107124fa946119d45b93fc8f8a8d77 + +WORKDIR /app + +RUN apt-get clean \ + && apt-get -y update + +RUN pip install --no-cache-dir --upgrade pip +RUN pip install --no-cache-dir pytest + +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV LANG C.UTF-8 + +CMD [ "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..8331ba8 --- /dev/null +++ b/server.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from pydantic import BaseModel + +from predictor import Predictor +from serving.app_factory import create_app + + +predictor = Predictor() + + +class Request(BaseModel): + inputs: str + + +class Response(BaseModel): + outputs: str + + +def handler(request: Request) -> Response: + prediction = predictor.predict(request.inputs) + return Response(outputs=prediction) + + +app = create_app(handler, Request, Response) diff --git a/serving/app_factory.py b/serving/app_factory.py new file mode 100644 index 0000000..90c53d5 --- /dev/null +++ b/serving/app_factory.py @@ -0,0 +1,19 @@ +from typing import Any, Callable + +from fastapi import FastAPI + + +def create_app(handler: Callable, request_type: Any, response_type: Any): + + app = FastAPI() + + @app.get("/hello") + async def hello() -> str: + return "hi" + + @app.post("/model") + async def inference(request: request_type) -> response_type: + response = handler(request) + return response + + return app From 2b0021cb92dd2da35152c40dc3afff98e4dd81ae Mon Sep 17 00:00:00 2001 From: Yongrae Jo Date: Sat, 10 Oct 2020 16:29:01 +0900 Subject: [PATCH 2/2] test: Add test_server --- tests/test_server.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/test_server.py diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..51be07d --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,12 @@ +from time import sleep + +from fastapi.testclient import TestClient + +from server import app + +client = TestClient(app) + + +def test_predict(): + request_response = client.post("/model", json={"inputs": "hey",}) + assert request_response.status_code == 200