mirror of https://github.com/kubeflow/examples.git
Add the web-ui for the mnist example (#473)
* Add the web-ui for the mnist example Copy the mnist web app from https://github.com/googlecodelabs/kubeflow-introduction * Update the web app * Change "server-name" argument to "model-name" because this is what is. * Update the prediction client code; The prediction code was copied from https://github.com/googlecodelabs/kubeflow-introduction and that model used slightly different values for the input names and outputs. * Add a test for the mnist_client code; currently it needs to be run manually. * Fix the label selector for the mnist service so that it matches the TFServing deployment. * Delete the old copy of mnist_client.py; we will go with the copy in ewb-ui from https://github.com/googlecodelabs/kubeflow-introduction * Delete model-deploy.yaml, model-train.yaml, and tf-user.yaml. The K8s resources for training and deploying the model are now in ks_app. * Fix tensorboard; tensorboard only partially works behind Ambassador. It seems like some requests don't work behind a reverse proxy. * Fix lint.
This commit is contained in:
parent
b3f06c204d
commit
6770b4adcc
|
|
@ -1 +1,2 @@
|
|||
build/**
|
||||
build/**
|
||||
web-ui/static/tmp/**
|
||||
120
mnist/README.md
120
mnist/README.md
|
|
@ -464,14 +464,32 @@ There are various ways to monitor workflow/training job. In addition to using `k
|
|||
|
||||
TODO: This section needs to be updated
|
||||
|
||||
Tensorboard is deployed just before training starts. To connect:
|
||||
Configure TensorBoard to point to your model location
|
||||
|
||||
```
|
||||
PODNAME=$(kubectl get pod -l app=tensorboard-${JOB_NAME} -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl port-forward ${PODNAME} 6006:6006
|
||||
ks param set tensorboard --env=${KSENV} logDir ${LOGDIR}
|
||||
|
||||
```
|
||||
|
||||
Tensorboard can now be accessed at [http://127.0.0.1:6006](http://127.0.0.1:6006).
|
||||
Assuming you followed the directions above if you used GCS you can use the following value
|
||||
|
||||
```
|
||||
LOGDIR=gs://${BUCKET}/${MODEL_PATH}
|
||||
```
|
||||
|
||||
Then you can deploy tensorboard
|
||||
|
||||
```
|
||||
ks apply ${KSENV} -c tensorboard
|
||||
```
|
||||
|
||||
To access tensorboard using port-forwarding
|
||||
|
||||
```
|
||||
kubectl -n jlewi port-forward service/tensorboard-tb 8090:80
|
||||
```
|
||||
Tensorboard can now be accessed at [http://127.0.0.1:8090](http://127.0.0.1:8090).
|
||||
|
||||
|
||||
## Serving the model
|
||||
|
||||
|
|
@ -534,96 +552,34 @@ TODO: Add instructions
|
|||
|
||||
TODO: Add instructions
|
||||
|
||||
### Create the K8s service
|
||||
## Web Front End
|
||||
|
||||
Next we need to create a K8s service to route traffic to our model
|
||||
The example comes with a simple web front end that can be used with your model.
|
||||
|
||||
To deploy the web front end
|
||||
|
||||
```
|
||||
ks apply jlewi -c mnist-service
|
||||
ks apply ${ENV} -c web-ui
|
||||
```
|
||||
|
||||
By default the workflow deploys our model via Tensorflow Serving. Included in this example is a client that can query your model and provide results:
|
||||
### Connecting via port forwarding
|
||||
|
||||
To connect to the web app via port-forwarding
|
||||
|
||||
```
|
||||
POD_NAME=$(kubectl get pod -l=app=mnist-${JOB_NAME} -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl port-forward ${POD_NAME} 9000:9000 &
|
||||
TF_MNIST_IMAGE_PATH=data/7.png python mnist_client.py
|
||||
kubectl -n ${NAMESPACE} port-forward svc/web-ui 8080:80
|
||||
```
|
||||
|
||||
This should result in output similar to this, depending on how well your model was trained:
|
||||
You should now be able to open up the web app at [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
### Using IAP on GCP
|
||||
|
||||
If you are using GCP and have set up IAP then you can access the web UI at
|
||||
|
||||
```
|
||||
outputs {
|
||||
key: "classes"
|
||||
value {
|
||||
dtype: DT_UINT8
|
||||
tensor_shape {
|
||||
dim {
|
||||
size: 1
|
||||
}
|
||||
}
|
||||
int_val: 7
|
||||
}
|
||||
}
|
||||
outputs {
|
||||
key: "predictions"
|
||||
value {
|
||||
dtype: DT_FLOAT
|
||||
tensor_shape {
|
||||
dim {
|
||||
size: 1
|
||||
}
|
||||
dim {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
float_val: 1.0
|
||||
float_val: 0.0
|
||||
float_val: 0.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
............................
|
||||
............................
|
||||
............................
|
||||
............................
|
||||
............................
|
||||
............................
|
||||
............................
|
||||
..............@@@@@@........
|
||||
..........@@@@@@@@@@........
|
||||
........@@@@@@@@@@@@........
|
||||
........@@@@@@@@.@@@........
|
||||
........@@@@....@@@@........
|
||||
................@@@@........
|
||||
...............@@@@.........
|
||||
...............@@@@.........
|
||||
...............@@@..........
|
||||
..............@@@@..........
|
||||
..............@@@...........
|
||||
.............@@@@...........
|
||||
.............@@@............
|
||||
............@@@@............
|
||||
............@@@.............
|
||||
............@@@.............
|
||||
...........@@@..............
|
||||
..........@@@@..............
|
||||
..........@@@@..............
|
||||
..........@@................
|
||||
............................
|
||||
Your model says the above number is... 7!
|
||||
https://${DEPLOYMENT}.endpoints.${PROJECT}.cloud.goog/${NAMESPACE}/mnist/
|
||||
```
|
||||
|
||||
You can also omit `TF_MNIST_IMAGE_PATH`, and the client will pick a random number from the mnist test data. Run it repeatedly and see how your model fares!
|
||||
|
||||
|
||||
## Conclusion and Next Steps
|
||||
|
||||
This is an example of what your machine learning can look like. Feel free to play with the tunables and see if you can increase your model's accuracy (increasing `model-train-steps` can go a long way).
|
||||
|
|
|
|||
|
|
@ -88,6 +88,12 @@
|
|||
contextDir: "."
|
||||
},
|
||||
|
||||
steps: modelSteps.steps + ksonnetSteps.steps,
|
||||
images: modelSteps.images + ksonnetSteps.images,
|
||||
local uiSteps = subGraphTemplate {
|
||||
name: "web-ui",
|
||||
dockerFile: "./web-ui/Dockerfile",
|
||||
contextDir: "./web-ui"
|
||||
},
|
||||
|
||||
steps: modelSteps.steps + ksonnetSteps.steps + uiSteps.steps,
|
||||
images: modelSteps.images + ksonnetSteps.images + uiSteps.images,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,10 +52,23 @@
|
|||
"mnist-service": {
|
||||
enablePrometheus: 'true',
|
||||
injectIstio: 'false',
|
||||
modelName: 'null',
|
||||
modelName: 'mnist',
|
||||
name: 'mnist-service',
|
||||
serviceType: 'ClusterIP',
|
||||
trafficRule: 'v1:100',
|
||||
},
|
||||
"tensorboard": {
|
||||
image: "tensorflow/tensorflow:1.11.0",
|
||||
logDir: "gs://example/to/model/logdir",
|
||||
name: "tensorboard",
|
||||
},
|
||||
"web-ui": {
|
||||
containerPort: 5000,
|
||||
image: "gcr.io/kubeflow-examples/mnist/web-ui:v20190112-v0.2-142-g3b38225",
|
||||
name: "web-ui",
|
||||
replicas: 1,
|
||||
servicePort: 80,
|
||||
type: "ClusterIP",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
// TODO: Generalize to use S3. We can follow the pattern of training that
|
||||
// takes parameters to specify environment variables and secret which can be customized
|
||||
// for GCS, S3 as needed.
|
||||
local env = std.extVar("__ksonnet/environments");
|
||||
local params = std.extVar("__ksonnet/params").components.tensorboard;
|
||||
|
||||
local k = import "k.libsonnet";
|
||||
|
||||
local name = params.name;
|
||||
local namespace = env.namespace;
|
||||
local service = {
|
||||
apiVersion: "v1",
|
||||
kind: "Service",
|
||||
metadata: {
|
||||
name: name + "-tb",
|
||||
namespace: env.namespace,
|
||||
annotations: {
|
||||
"getambassador.io/config":
|
||||
std.join("\n", [
|
||||
"---",
|
||||
"apiVersion: ambassador/v0",
|
||||
"kind: Mapping",
|
||||
"name: " + name + "_mapping",
|
||||
"prefix: /" + env.namespace + "/tensorboard/mnist",
|
||||
"rewrite: /",
|
||||
"service: " + name + "-tb." + namespace,
|
||||
"---",
|
||||
"apiVersion: ambassador/v0",
|
||||
"kind: Mapping",
|
||||
"name: " + name + "_mapping_data",
|
||||
"prefix: /" + env.namespace + "/tensorboard/mnist/data/",
|
||||
"rewrite: /data/",
|
||||
"service: " + name + "-tb." + namespace,
|
||||
]),
|
||||
}, //annotations
|
||||
},
|
||||
spec: {
|
||||
ports: [
|
||||
{
|
||||
name: "http",
|
||||
port: 80,
|
||||
targetPort: 80,
|
||||
},
|
||||
],
|
||||
selector: {
|
||||
app: "tensorboard",
|
||||
"tb-job": name,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
local deployment = {
|
||||
apiVersion: "apps/v1beta1",
|
||||
kind: "Deployment",
|
||||
metadata: {
|
||||
name: name + "-tb",
|
||||
namespace: env.namespace,
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: "tensorboard",
|
||||
"tb-job": name,
|
||||
},
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
command: [
|
||||
"/usr/local/bin/tensorboard",
|
||||
"--logdir=" + params.logDir,
|
||||
"--port=80",
|
||||
],
|
||||
image: params.image,
|
||||
name: "tensorboard",
|
||||
ports: [
|
||||
{
|
||||
containerPort: 80,
|
||||
},
|
||||
],
|
||||
env: [
|
||||
{
|
||||
name: "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
value: "/secret/gcp-credentials/user-gcp-sa.json",
|
||||
},
|
||||
],
|
||||
volumeMounts: [
|
||||
{
|
||||
mountPath: "/secret/gcp-credentials",
|
||||
name: "gcp-credentials",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
volumes: [
|
||||
{
|
||||
name: "gcp-credentials",
|
||||
secret: {
|
||||
secretName: "user-gcp-sa",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
std.prune(k.core.v1.list.new([service, deployment]))
|
||||
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
local env = std.extVar("__ksonnet/environments");
|
||||
local params = std.extVar("__ksonnet/params").components["web-ui"];
|
||||
[
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Service",
|
||||
"metadata": {
|
||||
"name": params.name,
|
||||
"namespace": env.namespace,
|
||||
annotations: {
|
||||
"getambassador.io/config":
|
||||
std.join("\n", [
|
||||
"---",
|
||||
"apiVersion: ambassador/v0",
|
||||
"kind: Mapping",
|
||||
"name: " + params.name + "_mapping",
|
||||
"prefix: /" + env.namespace + "/mnist/",
|
||||
"rewrite: /",
|
||||
"service: " + params.name + "." + env.namespace,
|
||||
]),
|
||||
}, //annotations
|
||||
},
|
||||
"spec": {
|
||||
"ports": [
|
||||
{
|
||||
"port": params.servicePort,
|
||||
"targetPort": params.containerPort
|
||||
}
|
||||
],
|
||||
"selector": {
|
||||
"app": params.name
|
||||
},
|
||||
"type": params.type
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "apps/v1beta2",
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": params.name,
|
||||
"namespace": env.namespace,
|
||||
},
|
||||
"spec": {
|
||||
"replicas": params.replicas,
|
||||
"selector": {
|
||||
"matchLabels": {
|
||||
"app": params.name
|
||||
},
|
||||
},
|
||||
"template": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"app": params.name
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": params.image,
|
||||
"name": params.name,
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": params.containerPort
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -22,9 +22,10 @@ local envParams = params + {
|
|||
namespace: 'jlewi',
|
||||
},
|
||||
"mnist-service"+: {
|
||||
name: 'jlewi-deploy-test',
|
||||
namespace: 'jlewi',
|
||||
modelBasePath: 'gs://kubeflow-ci_temp/mnist-jlewi/export',
|
||||
},
|
||||
tensorboard+: {
|
||||
logDir: 'gs://kubeflow-ci_temp/mnist-jlewi/',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
#!/usr/bin/env python2.7
|
||||
|
||||
import os
|
||||
import random
|
||||
import numpy
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import tensorflow as tf
|
||||
from tensorflow.examples.tutorials.mnist import input_data
|
||||
from tensorflow_serving.apis import predict_pb2
|
||||
from tensorflow_serving.apis import prediction_service_pb2
|
||||
|
||||
from grpc.beta import implementations
|
||||
|
||||
from mnist import MNIST # pylint: disable=no-name-in-module
|
||||
|
||||
TF_MODEL_SERVER_HOST = os.getenv("TF_MODEL_SERVER_HOST", "127.0.0.1")
|
||||
TF_MODEL_SERVER_PORT = int(os.getenv("TF_MODEL_SERVER_PORT", 9000))
|
||||
TF_DATA_DIR = os.getenv("TF_DATA_DIR", "/tmp/data/")
|
||||
TF_MNIST_IMAGE_PATH = os.getenv("TF_MNIST_IMAGE_PATH", None)
|
||||
TF_MNIST_TEST_IMAGE_NUMBER = int(os.getenv("TF_MNIST_TEST_IMAGE_NUMBER", -1))
|
||||
|
||||
if TF_MNIST_IMAGE_PATH != None:
|
||||
raw_image = Image.open(TF_MNIST_IMAGE_PATH)
|
||||
int_image = numpy.array(raw_image)
|
||||
image = numpy.reshape(int_image, 784).astype(numpy.float32)
|
||||
elif TF_MNIST_TEST_IMAGE_NUMBER > -1:
|
||||
test_data_set = input_data.read_data_sets(TF_DATA_DIR, one_hot=True).test
|
||||
image = test_data_set.images[TF_MNIST_TEST_IMAGE_NUMBER]
|
||||
else:
|
||||
test_data_set = input_data.read_data_sets(TF_DATA_DIR, one_hot=True).test
|
||||
image = random.choice(test_data_set.images)
|
||||
|
||||
channel = implementations.insecure_channel(
|
||||
TF_MODEL_SERVER_HOST, TF_MODEL_SERVER_PORT)
|
||||
stub = prediction_service_pb2.beta_create_PredictionService_stub(channel)
|
||||
|
||||
request = predict_pb2.PredictRequest()
|
||||
request.model_spec.name = "mnist"
|
||||
request.model_spec.signature_name = "serving_default"
|
||||
request.inputs['x'].CopyFrom(
|
||||
tf.contrib.util.make_tensor_proto(image, shape=[1, 28, 28]))
|
||||
|
||||
result = stub.Predict(request, 10.0) # 10 secs timeout
|
||||
|
||||
print(result)
|
||||
print(MNIST.display(image, threshold=0))
|
||||
print("Your model says the above number is... %d!" %
|
||||
result.outputs["classes"].int_val[0])
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Workflow
|
||||
metadata:
|
||||
generateName: tf-workflow-
|
||||
spec:
|
||||
entrypoint: deploy-model
|
||||
# Parameters can be passed/overridden via the argo CLI.
|
||||
# To override the printed message, run `argo submit` with the -p option:
|
||||
# $ argo submit examples/arguments-parameters.yaml -p message="goodbye world"
|
||||
arguments:
|
||||
parameters:
|
||||
- name: workflow
|
||||
value: workflow-name
|
||||
templates:
|
||||
- name: deploy-model
|
||||
steps:
|
||||
- - name: get-workflow-info
|
||||
template: get-workflow-info
|
||||
- - name: serve-model
|
||||
template: tf-inference
|
||||
arguments:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-model-url}}"
|
||||
- name: s3-exported-url
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-exported-url}}"
|
||||
- name: aws-secret
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.aws-secret}}"
|
||||
- name: namespace
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.namespace}}"
|
||||
- name: aws-region
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.aws-region}}"
|
||||
- name: s3-endpoint
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-endpoint}}"
|
||||
- name: s3-use-https
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-use-https}}"
|
||||
- name: s3-verify-ssl
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-verify-ssl}}"
|
||||
- name: job-name
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.job-name}}"
|
||||
- name: tf-serving-image
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.tf-serving-image}}"
|
||||
- name: model-serving-servicetype
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.model-serving-servicetype}}"
|
||||
- name: model-serving-ks-url
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.model-serving-ks-url}}"
|
||||
- name: model-serving-ks-tag
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.model-serving-ks-tag}}"
|
||||
- name: model-name
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.model-name}}"
|
||||
- name: get-workflow-info
|
||||
container:
|
||||
image: nervana/circleci:master
|
||||
imagePullPolicy: Always
|
||||
command: ["bash", "-c", "-x", 'for var in s3-model-url s3-exported-url; do kubectl get workflow {{workflow.parameters.workflow}} -o json | jq -r ".status.nodes[] | select(.name|contains(\"get-workflow-info\")) | .outputs.parameters[] | select(.name == \"${var}\") | .value" > /tmp/${var} ; done; for var in job-name namespace aws-secret aws-region s3-endpoint s3-use-https s3-verify-ssl tf-serving-image model-serving-servicetype model-serving-ks-url model-serving-ks-tag model-name; do kubectl get workflow {{workflow.parameters.workflow}} -o jsonpath="{.spec.arguments.parameters[?(.name==\"${var}\")].value}" > /tmp/${var}; done']
|
||||
outputs:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
valueFrom:
|
||||
path: /tmp/s3-model-url
|
||||
- name: s3-exported-url
|
||||
valueFrom:
|
||||
path: /tmp/s3-exported-url
|
||||
- name: aws-secret
|
||||
valueFrom:
|
||||
path: /tmp/aws-secret
|
||||
- name: namespace
|
||||
valueFrom:
|
||||
path: /tmp/namespace
|
||||
- name: aws-region
|
||||
valueFrom:
|
||||
path: /tmp/aws-region
|
||||
- name: s3-endpoint
|
||||
valueFrom:
|
||||
path: /tmp/s3-endpoint
|
||||
- name: s3-use-https
|
||||
valueFrom:
|
||||
path: /tmp/s3-use-https
|
||||
- name: s3-verify-ssl
|
||||
valueFrom:
|
||||
path: /tmp/s3-verify-ssl
|
||||
- name: job-name
|
||||
valueFrom:
|
||||
path: /tmp/job-name
|
||||
- name: tf-serving-image
|
||||
valueFrom:
|
||||
path: /tmp/tf-serving-image
|
||||
- name: model-serving-servicetype
|
||||
valueFrom:
|
||||
path: /tmp/model-serving-servicetype
|
||||
- name: model-serving-ks-url
|
||||
valueFrom:
|
||||
path: /tmp/model-serving-ks-url
|
||||
- name: model-serving-ks-tag
|
||||
valueFrom:
|
||||
path: /tmp/model-serving-ks-tag
|
||||
- name: model-name
|
||||
valueFrom:
|
||||
path: /tmp/model-name
|
||||
- name: tf-inference
|
||||
inputs:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
- name: s3-exported-url
|
||||
- name: aws-secret
|
||||
- name: namespace
|
||||
- name: aws-region
|
||||
- name: s3-endpoint
|
||||
- name: s3-use-https
|
||||
- name: s3-verify-ssl
|
||||
- name: job-name
|
||||
- name: tf-serving-image
|
||||
- name: model-serving-servicetype
|
||||
- name: model-serving-ks-url
|
||||
- name: model-serving-ks-tag
|
||||
- name: model-name
|
||||
script:
|
||||
image: elsonrodriguez/ksonnet:0.10.1
|
||||
command: ["/ksonnet-entrypoint.sh"]
|
||||
source: |
|
||||
ks init my-model-server
|
||||
cd my-model-server
|
||||
ks registry add kubeflow {{inputs.parameters.model-serving-ks-url}}
|
||||
ks pkg install kubeflow/tf-serving@{{inputs.parameters.model-serving-ks-tag}}
|
||||
ks env add default
|
||||
# TODO change mnist name to be specific to a job. Right now mnist name is required to serve the model.
|
||||
ks generate tf-serving {{inputs.parameters.model-name}} --name=mnist-{{inputs.parameters.job-name}} --namespace={{inputs.parameters.namespace}} --model_path={{inputs.parameters.s3-exported-url}}
|
||||
ks param set {{inputs.parameters.model-name}} model_server_image {{inputs.parameters.tf-serving-image}}
|
||||
ks param set {{inputs.parameters.model-name}} model_name {{inputs.parameters.model-name}}
|
||||
ks param set {{inputs.parameters.model-name}} namespace {{inputs.parameters.namespace}}
|
||||
ks param set {{inputs.parameters.model-name}} service_type {{inputs.parameters.model-serving-servicetype}}
|
||||
ks param set {{inputs.parameters.model-name}} s3_create_secret false
|
||||
ks param set {{inputs.parameters.model-name}} s3_secret_name {{inputs.parameters.aws-secret}}
|
||||
ks param set {{inputs.parameters.model-name}} s3_secret_accesskeyid_key_name awsAccessKeyID
|
||||
ks param set {{inputs.parameters.model-name}} s3_secret_secretaccesskey_key_name awsSecretAccessKey
|
||||
ks param set {{inputs.parameters.model-name}} s3_aws_region {{inputs.parameters.aws-region}}
|
||||
ks param set {{inputs.parameters.model-name}} s3_endpoint {{inputs.parameters.s3-endpoint}}
|
||||
ks param set {{inputs.parameters.model-name}} s3_use_https {{inputs.parameters.s3-use-https}} --as-string
|
||||
ks param set {{inputs.parameters.model-name}} s3_verify_ssl {{inputs.parameters.s3-verify-ssl}} --as-string
|
||||
ks apply default -c {{inputs.parameters.model-name}}
|
||||
#FIXME This doesn't actually work in the current version of argo. We're using a default of `tf-user` in the container entrypoint for now.
|
||||
env:
|
||||
- name: SERVICE_ACCOUNT
|
||||
value: tf-user
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Workflow
|
||||
metadata:
|
||||
generateName: tf-workflow-
|
||||
spec:
|
||||
entrypoint: tests
|
||||
onExit: exit-handler
|
||||
# Parameters can be passed/overridden via the argo CLI.
|
||||
# To override the printed message, run `argo submit` with the -p option:
|
||||
# $ argo submit examples/arguments-parameters.yaml -p message="goodbye world"
|
||||
arguments:
|
||||
parameters:
|
||||
- name: tf-worker # number of tf workers
|
||||
value: 1
|
||||
- name: tf-ps # number of tf parameter servers
|
||||
value: 2
|
||||
- name: tf-model-image
|
||||
value: elsonrodriguez/mytfmodel:1.7
|
||||
- name: tf-serving-image #FIXME this image is a mirror of a private kubeflow-ci image, once we're building images swap this out. https://github.com/kubeflow/kubeflow/blob/dcf4adfe2dd1cec243647f3dd05d7c26246fddb1/components/k8s-model-server/images/Dockerfile.cpu
|
||||
value: elsonrodriguez/model-server:1.6
|
||||
- name: tf-tensorboard-image
|
||||
value: tensorflow/tensorflow:1.7.0
|
||||
- name: ks-image
|
||||
value: elsonrodriguez/ksonnet:0.10.1
|
||||
- name: model-name
|
||||
value: mnist
|
||||
- name: model-hidden-units
|
||||
value: 100
|
||||
- name: model-train-steps
|
||||
value: 200
|
||||
- name: model-batch-size
|
||||
value: 100
|
||||
- name: model-learning-rate
|
||||
value: 0.01
|
||||
- name: model-serving
|
||||
value: true
|
||||
- name: model-serving-servicetype
|
||||
value: ClusterIP
|
||||
- name: model-serving-ks-url
|
||||
value: github.com/kubeflow/kubeflow/tree/master/kubeflow
|
||||
- name: model-serving-ks-tag
|
||||
value: 1f474f30
|
||||
- name: job-name
|
||||
value: myjob
|
||||
- name: namespace
|
||||
value: default
|
||||
- name: s3-data-url
|
||||
value: s3://mybucket/data/mnist/
|
||||
- name: s3-train-base-url
|
||||
value: s3://mybucket/models
|
||||
- name: aws-endpoint-url
|
||||
value: https://s3.us-west-1.amazonaws.com
|
||||
- name: s3-endpoint
|
||||
value: s3.us-west-1.amazonaws.com
|
||||
- name: s3-use-https
|
||||
value: true
|
||||
- name: s3-verify-ssl
|
||||
value: true
|
||||
- name: aws-region
|
||||
value: us-west-1
|
||||
- name: aws-secret
|
||||
value: aws-creds
|
||||
volumes:
|
||||
- name: training-data
|
||||
emptyDir: {}
|
||||
- name: training-output
|
||||
templates:
|
||||
- name: tests
|
||||
steps:
|
||||
- - name: get-workflow-info
|
||||
template: get-workflow-info
|
||||
- - name: tensorboard
|
||||
template: tf-tensorboard
|
||||
arguments:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-model-url}}"
|
||||
- - name: train-model
|
||||
template: tf-train
|
||||
arguments:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-model-url}}"
|
||||
- - name: serve-model
|
||||
template: tf-inference
|
||||
arguments:
|
||||
parameters:
|
||||
- name: s3-exported-url
|
||||
value: "{{steps.get-workflow-info.outputs.parameters.s3-exported-url}}"
|
||||
when: "{{workflow.parameters.model-serving}} == true"
|
||||
- name: exit-handler
|
||||
steps:
|
||||
- - name: cleanup
|
||||
template: clean
|
||||
- name: get-workflow-info
|
||||
container:
|
||||
image: nervana/circleci:master
|
||||
imagePullPolicy: Always
|
||||
command: ["bash", "-c", "echo '{{workflow.parameters.s3-train-base-url}}/{{workflow.parameters.job-name}}/' | tr -d '[:space:]' > /tmp/s3-model-url; echo '{{workflow.parameters.s3-train-base-url}}/{{workflow.parameters.job-name}}/export/{{workflow.parameters.model-name}}/' | tr -d '[:space:]' > /tmp/s3-exported-url"]
|
||||
outputs:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
valueFrom:
|
||||
path: /tmp/s3-model-url
|
||||
- name: s3-exported-url
|
||||
valueFrom:
|
||||
path: /tmp/s3-exported-url
|
||||
- name: tf-train
|
||||
inputs:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
resource:
|
||||
action: apply
|
||||
# NOTE: need to detect master node complete
|
||||
successCondition: status.tfReplicaStatuses.Master.succeeded == 1
|
||||
manifest: |
|
||||
apiVersion: "kubeflow.org/v1alpha2"
|
||||
kind: "TFJob"
|
||||
metadata:
|
||||
name: {{workflow.parameters.job-name}}
|
||||
namespace: {{workflow.parameters.namespace}}
|
||||
spec:
|
||||
tfReplicaSpecs:
|
||||
Master:
|
||||
replicas: 1
|
||||
template:
|
||||
spec:
|
||||
serviceAccountName: tf-job-operator
|
||||
containers:
|
||||
- image: {{workflow.parameters.tf-model-image}}
|
||||
name: tensorflow
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: TF_MODEL_DIR
|
||||
value: {{inputs.parameters.s3-model-url}}
|
||||
- name: TF_EXPORT_DIR
|
||||
value: {{workflow.parameters.model-name}}
|
||||
- name: TF_TRAIN_STEPS
|
||||
value: "{{workflow.parameters.model-train-steps}}"
|
||||
- name: TF_BATCH_SIZE
|
||||
value: "{{workflow.parameters.model-batch-size}}"
|
||||
- name: TF_LEARNING_RATE
|
||||
value: "{{workflow.parameters.model-learning-rate}}"
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
key: awsAccessKeyID
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
key: awsSecretAccessKey
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: AWS_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_USE_HTTPS
|
||||
value: "{{workflow.parameters.s3-use-https}}"
|
||||
- name: S3_VERIFY_SSL
|
||||
value: "{{workflow.parameters.s3-verify-ssl}}"
|
||||
- name: S3_ENDPOINT
|
||||
value: {{workflow.parameters.s3-endpoint}}
|
||||
restartPolicy: OnFailure
|
||||
Worker:
|
||||
replicas: {{workflow.parameters.tf-worker}}
|
||||
template:
|
||||
spec:
|
||||
serviceAccountName: tf-job-operator
|
||||
containers:
|
||||
- image: {{workflow.parameters.tf-model-image}}
|
||||
name: tensorflow
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: TF_MODEL_DIR
|
||||
value: {{inputs.parameters.s3-model-url}}
|
||||
- name: TF_EXPORT_DIR
|
||||
value: {{workflow.parameters.model-name}}
|
||||
- name: TF_TRAIN_STEPS
|
||||
value: "{{workflow.parameters.model-train-steps}}"
|
||||
- name: TF_BATCH_SIZE
|
||||
value: "{{workflow.parameters.model-batch-size}}"
|
||||
- name: TF_LEARNING_RATE
|
||||
value: "{{workflow.parameters.model-learning-rate}}"
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
key: awsAccessKeyID
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
key: awsSecretAccessKey
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: AWS_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_USE_HTTPS
|
||||
value: "{{workflow.parameters.s3-use-https}}"
|
||||
- name: S3_VERIFY_SSL
|
||||
value: "{{workflow.parameters.s3-verify-ssl}}"
|
||||
- name: S3_ENDPOINT
|
||||
value: {{workflow.parameters.s3-endpoint}}
|
||||
restartPolicy: OnFailure
|
||||
Ps:
|
||||
replicas: {{workflow.parameters.tf-ps}}
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- image: {{workflow.parameters.tf-model-image}}
|
||||
name: tensorflow
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: TF_MODEL_DIR
|
||||
value: {{inputs.parameters.s3-model-url}}
|
||||
- name: TF_EXPORT_DIR
|
||||
value: {{workflow.parameters.model-name}}
|
||||
- name: TF_TRAIN_STEPS
|
||||
value: "{{workflow.parameters.model-train-steps}}"
|
||||
- name: TF_BATCH_SIZE
|
||||
value: "{{workflow.parameters.model-batch-size}}"
|
||||
- name: TF_LEARNING_RATE
|
||||
value: "{{workflow.parameters.model-learning-rate}}"
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
key: awsAccessKeyID
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
key: awsSecretAccessKey
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: AWS_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_USE_HTTPS
|
||||
value: "{{workflow.parameters.s3-use-https}}"
|
||||
- name: S3_VERIFY_SSL
|
||||
value: "{{workflow.parameters.s3-verify-ssl}}"
|
||||
- name: S3_ENDPOINT
|
||||
value: {{workflow.parameters.s3-endpoint}}
|
||||
restartPolicy: OnFailure
|
||||
- name: tf-tensorboard
|
||||
inputs:
|
||||
parameters:
|
||||
- name: s3-model-url
|
||||
resource:
|
||||
action: apply
|
||||
manifest: |
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: tensorboard-{{workflow.parameters.job-name}}
|
||||
name: tensorboard-{{workflow.parameters.job-name}}
|
||||
namespace: {{workflow.parameters.namespace}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: tensorboard-{{workflow.parameters.job-name}}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: tensorboard-{{workflow.parameters.job-name}}
|
||||
spec:
|
||||
containers:
|
||||
- name: tensorboard-{{workflow.parameters.job-name}}
|
||||
image: {{workflow.parameters.tf-tensorboard-image}}
|
||||
imagePullPolicy: Always
|
||||
command:
|
||||
- /usr/local/bin/tensorboard
|
||||
args:
|
||||
- --logdir
|
||||
- {{inputs.parameters.s3-model-url}}
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: awsAccessKeyID
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: awsSecretAccessKey
|
||||
name: {{workflow.parameters.aws-secret}}
|
||||
- name: AWS_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_REGION
|
||||
value: {{workflow.parameters.aws-region}}
|
||||
- name: S3_USE_HTTPS
|
||||
value: "{{workflow.parameters.s3-use-https}}"
|
||||
- name: S3_VERIFY_SSL
|
||||
value: "{{workflow.parameters.s3-verify-ssl}}"
|
||||
- name: S3_ENDPOINT
|
||||
value: {{workflow.parameters.s3-endpoint}}
|
||||
ports:
|
||||
- containerPort: 6006
|
||||
protocol: TCP
|
||||
dnsPolicy: ClusterFirst
|
||||
restartPolicy: Always
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app: tensorboard-{{workflow.parameters.job-name}}
|
||||
name: tensorboard-{{workflow.parameters.job-name}}
|
||||
namespace: {{workflow.parameters.namespace}}
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
targetPort: 6006
|
||||
selector:
|
||||
app: tensorboard-{{workflow.parameters.job-name}}
|
||||
sessionAffinity: None
|
||||
type: ClusterIP
|
||||
- name: tf-inference
|
||||
inputs:
|
||||
parameters:
|
||||
- name: s3-exported-url
|
||||
script:
|
||||
image: "{{workflow.parameters.ks-image}}"
|
||||
command: ["/ksonnet-entrypoint.sh"]
|
||||
source: |
|
||||
ks init my-model-server
|
||||
cd my-model-server
|
||||
ks registry add kubeflow {{workflow.parameters.model-serving-ks-url}}
|
||||
ks pkg install kubeflow/tf-serving@{{workflow.parameters.model-serving-ks-tag}}
|
||||
ks env add default
|
||||
# TODO change mnist name to be specific to a job. Right now mnist name is required to serve the model.
|
||||
ks generate tf-serving {{workflow.parameters.model-name}} --name=mnist-{{workflow.parameters.job-name}} --namespace={{workflow.parameters.namespace}} --model_path={{inputs.parameters.s3-exported-url}}
|
||||
ks param set {{workflow.parameters.model-name}} model_server_image {{workflow.parameters.tf-serving-image}}
|
||||
ks param set {{workflow.parameters.model-name}} model_name {{workflow.parameters.model-name}}
|
||||
ks param set {{workflow.parameters.model-name}} namespace {{workflow.parameters.namespace}}
|
||||
ks param set {{workflow.parameters.model-name}} service_type {{workflow.parameters.model-serving-servicetype}}
|
||||
ks param set {{workflow.parameters.model-name}} s3_create_secret false
|
||||
ks param set {{workflow.parameters.model-name}} s3_secret_name {{workflow.parameters.aws-secret}}
|
||||
ks param set {{workflow.parameters.model-name}} s3_secret_accesskeyid_key_name awsAccessKeyID
|
||||
ks param set {{workflow.parameters.model-name}} s3_secret_secretaccesskey_key_name awsSecretAccessKey
|
||||
ks param set {{workflow.parameters.model-name}} s3_aws_region {{workflow.parameters.aws-region}}
|
||||
ks param set {{workflow.parameters.model-name}} s3_endpoint {{workflow.parameters.s3-endpoint}}
|
||||
ks param set {{workflow.parameters.model-name}} s3_use_https {{workflow.parameters.s3-use-https}} --as-string
|
||||
ks param set {{workflow.parameters.model-name}} s3_verify_ssl {{workflow.parameters.s3-verify-ssl}} --as-string
|
||||
ks apply default -c {{workflow.parameters.model-name}}
|
||||
#FIXME This doesn't actually work in the current version of argo. We're using a default of `tf-user` in the container entrypoint for now.
|
||||
env:
|
||||
- name: SERVICE_ACCOUNT
|
||||
value: tf-user
|
||||
- name: clean
|
||||
container:
|
||||
image: nervana/circleci:master
|
||||
imagePullPolicy: Always
|
||||
command: ["bash", "-c", "kubectl delete tfjob {{workflow.parameters.job-name}} || true"]
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: tf-user
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- pods
|
||||
- pods/exec
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- configmaps
|
||||
- serviceaccounts
|
||||
- secrets
|
||||
verbs:
|
||||
- get
|
||||
- watch
|
||||
- list
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- persistentvolumeclaims
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- services
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- apps
|
||||
- extensions
|
||||
resources:
|
||||
- deployments
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- patch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- argoproj.io
|
||||
resources:
|
||||
- workflows
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- kubeflow.org
|
||||
resources:
|
||||
- tfjobs
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- patch
|
||||
- delete
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: tf-user
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: tf-user
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: tf-user
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: tf-user
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
FROM ubuntu:16.04
|
||||
MAINTAINER "Daniel Sanche"
|
||||
|
||||
# add TF dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
libfreetype6-dev \
|
||||
libpng12-dev \
|
||||
libzmq3-dev \
|
||||
pkg-config \
|
||||
python3 \
|
||||
python-dev \
|
||||
rsync \
|
||||
software-properties-common \
|
||||
unzip \
|
||||
&& \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# add python dependencies
|
||||
RUN curl -O https://bootstrap.pypa.io/get-pip.py && \
|
||||
python get-pip.py && \
|
||||
rm get-pip.py
|
||||
|
||||
RUN pip --no-cache-dir install \
|
||||
Pillow \
|
||||
h5py \
|
||||
ipykernel \
|
||||
numpy \
|
||||
tensorflow \
|
||||
tensorflow-serving-api \
|
||||
flask \
|
||||
&& \
|
||||
python -m ipykernel.kernelspec
|
||||
|
||||
# show python logs as they occur
|
||||
ENV PYTHONUNBUFFERED=0
|
||||
|
||||
# add project files
|
||||
ADD *.py /home/
|
||||
ADD templates/* /home/templates/
|
||||
ADD static/styles /home/static/styles/
|
||||
RUN mkdir /home/static/tmp/
|
||||
ADD static/scripts/ /home/static/scripts/
|
||||
|
||||
# start server on port 5000
|
||||
WORKDIR /home/
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT python flask_server.py
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,14 @@
|
|||
# web-ui
|
||||
|
||||
The files in this folder define a web interface that can be used to interact with a TensorFlow server
|
||||
|
||||
- flask_server.py
|
||||
- main server code. Handles incoming requests, and renders HTML from template
|
||||
- mnist_client.py
|
||||
- code to interact with TensorFlow model server
|
||||
- takes in an image and server details, and returns the server's response
|
||||
- Dockerfile
|
||||
- builds a runnable container out of the files in this directory
|
||||
|
||||
---
|
||||
This is not an officially supported Google product
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
'''
|
||||
Copyright 2018 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
'''
|
||||
|
||||
import logging
|
||||
import os
|
||||
from threading import Timer
|
||||
import uuid
|
||||
|
||||
from flask import Flask, render_template, request
|
||||
from mnist_client import get_prediction, random_mnist
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
# handle requests to the server
|
||||
@app.route("/")
|
||||
def main():
|
||||
# get url parameters for HTML template
|
||||
name_arg = request.args.get('name', 'mnist')
|
||||
addr_arg = request.args.get('addr', 'mnist-service')
|
||||
port_arg = request.args.get('port', '9000')
|
||||
args = {"name": name_arg, "addr": addr_arg, "port": port_arg}
|
||||
logging.info("Request args: %s", args)
|
||||
|
||||
output = None
|
||||
connection = {"text": "", "success": False}
|
||||
img_id = str(uuid.uuid4())
|
||||
img_path = "static/tmp/" + img_id + ".png"
|
||||
try:
|
||||
# get a random test MNIST image
|
||||
x, y, _ = random_mnist(img_path)
|
||||
# get prediction from TensorFlow server
|
||||
pred, scores, ver = get_prediction(x,
|
||||
server_host=addr_arg,
|
||||
server_port=int(port_arg),
|
||||
server_name=name_arg,
|
||||
timeout=10)
|
||||
# if no exceptions thrown, server connection was a success
|
||||
connection["text"] = "Connected (model version: " + str(ver) + ")"
|
||||
connection["success"] = True
|
||||
# parse class confidence scores from server prediction
|
||||
scores_dict = []
|
||||
for i in range(0, 10):
|
||||
scores_dict += [{"index": str(i), "val": scores[i]}]
|
||||
output = {"truth": y, "prediction": pred,
|
||||
"img_path": img_path, "scores": scores_dict}
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logging.info("Exception occured: %s", e)
|
||||
# server connection failed
|
||||
connection["text"] = "Exception making request: {0}".format(e)
|
||||
# after 10 seconds, delete cached image file from server
|
||||
t = Timer(10.0, remove_resource, [img_path])
|
||||
t.start()
|
||||
# render results using HTML template
|
||||
return render_template('index.html', output=output,
|
||||
connection=connection, args=args)
|
||||
|
||||
|
||||
def remove_resource(path):
|
||||
"""
|
||||
attempt to delete file from path. Used to clean up MNIST testing images
|
||||
|
||||
:param path: the path of the file to delete
|
||||
"""
|
||||
try:
|
||||
os.remove(path)
|
||||
print("removed " + path)
|
||||
except OSError:
|
||||
print("no file at " + path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format=('%(levelname)s|%(asctime)s'
|
||||
'|%(pathname)s|%(lineno)d| %(message)s'),
|
||||
datefmt='%Y-%m-%dT%H:%M:%S',
|
||||
)
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
logging.info("Starting flask.")
|
||||
app.run(debug=True, host='0.0.0.0')
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env python2.7
|
||||
'''
|
||||
Copyright 2018 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
'''
|
||||
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import logging
|
||||
|
||||
from grpc.beta import implementations
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
from tensorflow.examples.tutorials.mnist import input_data
|
||||
from tensorflow_serving.apis import predict_pb2
|
||||
from tensorflow_serving.apis import prediction_service_pb2
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def get_prediction(image, server_host='127.0.0.1', server_port=9000,
|
||||
server_name="server", timeout=10.0):
|
||||
"""
|
||||
Retrieve a prediction from a TensorFlow model server
|
||||
|
||||
:param image: a MNIST image represented as a 1x784 array
|
||||
:param server_host: the address of the TensorFlow server
|
||||
:param server_port: the port used by the server
|
||||
:param server_name: the name of the server
|
||||
:param timeout: the amount of time to wait for a prediction to complete
|
||||
:return 0: the integer predicted in the MNIST image
|
||||
:return 1: the confidence scores for all classes
|
||||
:return 2: the version number of the model handling the request
|
||||
"""
|
||||
|
||||
print("connecting to:%s:%i" % (server_host, server_port))
|
||||
# initialize to server connection
|
||||
channel = implementations.insecure_channel(server_host, server_port)
|
||||
stub = prediction_service_pb2.beta_create_PredictionService_stub(channel)
|
||||
|
||||
# build request
|
||||
request = predict_pb2.PredictRequest()
|
||||
request.model_spec.name = server_name
|
||||
request.model_spec.signature_name = 'serving_default'
|
||||
request.inputs['x'].CopyFrom(
|
||||
tf.contrib.util.make_tensor_proto(image, shape=image.shape))
|
||||
|
||||
# retrieve results
|
||||
result = stub.Predict(request, timeout)
|
||||
resultVal = result.outputs["classes"].int_val[0]
|
||||
scores = result.outputs['predictions'].float_val
|
||||
version = result.outputs["classes"].int_val[0]
|
||||
return resultVal, scores, version
|
||||
|
||||
|
||||
def random_mnist(save_path=None):
|
||||
"""
|
||||
Pull a random image out of the MNIST test dataset
|
||||
Optionally save the selected image as a file to disk
|
||||
|
||||
:param savePath: the path to save the file to. If None, file is not saved
|
||||
:return 0: a 1x784 representation of the MNIST image
|
||||
:return 1: the ground truth label associated with the image
|
||||
:return 2: a bool representing whether the image file was saved to disk
|
||||
"""
|
||||
|
||||
mnist = input_data.read_data_sets("MNIST_data/", one_hot=True)
|
||||
batch_size = 1
|
||||
batch_x, batch_y = mnist.test.next_batch(batch_size)
|
||||
saved = False
|
||||
if save_path is not None:
|
||||
# save image file to disk
|
||||
try:
|
||||
data = (batch_x * 255).astype(np.uint8).reshape(28, 28)
|
||||
img = Image.fromarray(data, 'L')
|
||||
img.save(save_path)
|
||||
saved = True
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logging.error("There was a problem saving the image; %s", e)
|
||||
return batch_x, np.argmax(batch_y), saved
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""Test mnist_client.
|
||||
|
||||
This file tests that we can send predictions to the model.
|
||||
|
||||
It is an integration test as it depends on having access to
|
||||
a deployed model.
|
||||
|
||||
Python Path Requirements:
|
||||
kubeflow/testing/py - https://github.com/kubeflow/testing/tree/master/py
|
||||
* Provides utilities for testing
|
||||
|
||||
Manually running the test
|
||||
1. Configure your KUBECONFIG file to point to the desired cluster
|
||||
2. Use kubectl port-forward to forward a local port
|
||||
to the gRPC port of TFServing; e.g.
|
||||
kubectl -n ${NAMESPACE} port-forward service/mnist-service 9000:9000
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import mnist_client
|
||||
|
||||
from py import test_runner
|
||||
|
||||
from kubeflow.testing import test_util
|
||||
|
||||
class MnistClientTest(test_util.TestCase):
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
super(MnistClientTest, self).__init__(class_name="MnistClientTest",
|
||||
name="MnistClientTest")
|
||||
|
||||
def test_predict(self): # pylint: disable=no-self-use
|
||||
this_dir = os.path.dirname(__file__)
|
||||
data_dir = os.path.join(this_dir, "..", "data")
|
||||
img_path = os.path.abspath(data_dir)
|
||||
|
||||
x, _, _ = mnist_client.random_mnist(img_path)
|
||||
|
||||
server_host = "localhost"
|
||||
server_port = 9000
|
||||
model_name = "mnist"
|
||||
# get prediction from TensorFlow server
|
||||
pred, scores, _ = mnist_client.get_prediction(
|
||||
x, server_host=server_host, server_port=server_port,
|
||||
server_name=model_name, timeout=10)
|
||||
|
||||
if pred < 0 or pred >= 10:
|
||||
raise ValueError("Prediction {0} is not in the range [0, 9]".format(pred))
|
||||
|
||||
if len(scores) != 10:
|
||||
raise ValueError("Scores should have dimension 10. Got {0}".format(
|
||||
scores))
|
||||
# TODO(jlewi): Should we do any additional validation?
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_runner.main(module=__name__)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Copyright 2015 Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
html, body {
|
||||
font-family: 'Roboto', 'Helvetica', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.mdl-demo .mdl-layout__header-row {
|
||||
padding-left: 40px;
|
||||
}
|
||||
.mdl-demo .mdl-layout.is-small-screen .mdl-layout__header-row h3 {
|
||||
font-size: inherit;
|
||||
}
|
||||
.mdl-demo .mdl-layout__tab-bar-button {
|
||||
display: none;
|
||||
}
|
||||
.mdl-demo .mdl-layout.is-small-screen .mdl-layout__tab-bar .mdl-button {
|
||||
display: none;
|
||||
}
|
||||
.mdl-demo .mdl-layout:not(.is-small-screen) .mdl-layout__tab-bar,
|
||||
.mdl-demo .mdl-layout:not(.is-small-screen) .mdl-layout__tab-bar-container {
|
||||
overflow: visible;
|
||||
}
|
||||
.mdl-demo .mdl-layout__tab-bar-container {
|
||||
height: 64px;
|
||||
}
|
||||
.mdl-demo .mdl-layout__tab-bar {
|
||||
padding: 0;
|
||||
padding-left: 16px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.mdl-demo .mdl-layout__tab-bar .mdl-layout__tab {
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
}
|
||||
.mdl-demo .mdl-layout__tab-bar .mdl-layout__tab.is-active::after {
|
||||
background-color: white;
|
||||
height: 4px;
|
||||
}
|
||||
.mdl-demo main > .mdl-layout__tab-panel {
|
||||
padding: 8px;
|
||||
padding-top: 48px;
|
||||
}
|
||||
.mdl-demo .mdl-card {
|
||||
height: auto;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mdl-demo .mdl-card > * {
|
||||
height: auto;
|
||||
}
|
||||
.mdl-demo .mdl-card .mdl-card__supporting-text {
|
||||
margin: 40px;
|
||||
-webkit-flex-grow: 1;
|
||||
-ms-flex-positive: 1;
|
||||
flex-grow: 1;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
width: calc(100% - 80px);
|
||||
}
|
||||
.mdl-demo.mdl-demo .mdl-card__supporting-text h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.mdl-demo .mdl-card__actions {
|
||||
margin: 0;
|
||||
padding: 4px 40px;
|
||||
color: inherit;
|
||||
}
|
||||
.mdl-demo .mdl-card__actions a {
|
||||
color: #00BCD4;
|
||||
margin: 0;
|
||||
}
|
||||
.mdl-demo .mdl-card__actions a:hover,
|
||||
.mdl-demo .mdl-card__actions a:active {
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
.mdl-demo .mdl-card__supporting-text + .mdl-card__actions {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.mdl-demo #add {
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
top: 36px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.mdl-demo .mdl-layout__content section:not(:last-of-type) {
|
||||
position: relative;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.mdl-demo section.section--center {
|
||||
max-width: 860px;
|
||||
}
|
||||
.mdl-demo #features section.section--center {
|
||||
max-width: 620px;
|
||||
}
|
||||
.mdl-demo section > header{
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mdl-demo section > .section__play-btn {
|
||||
min-height: 200px;
|
||||
}
|
||||
.mdl-demo section > header > .material-icons {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.mdl-demo section > button {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
.mdl-demo section .section__circle {
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
-webkit-justify-content: flex-start;
|
||||
-ms-flex-pack: start;
|
||||
justify-content: flex-start;
|
||||
-webkit-flex-grow: 0;
|
||||
-ms-flex-positive: 0;
|
||||
flex-grow: 0;
|
||||
-webkit-flex-shrink: 1;
|
||||
-ms-flex-negative: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.mdl-demo section .section__text {
|
||||
-webkit-flex-grow: 1;
|
||||
-ms-flex-positive: 1;
|
||||
flex-grow: 1;
|
||||
-webkit-flex-shrink: 0;
|
||||
-ms-flex-negative: 0;
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.mdl-demo section .section__text h5 {
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.mdl-demo section .section__text a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.mdl-demo section .section__circle-container > .section__circle-container__circle {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 32px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.mdl-demo section.section--footer .section__circle--big {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50px;
|
||||
margin: 8px 32px;
|
||||
}
|
||||
.mdl-demo .is-small-screen section.section--footer .section__circle--big {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 25px;
|
||||
margin: 8px 16px;
|
||||
}
|
||||
.mdl-demo section.section--footer {
|
||||
padding: 64px 0;
|
||||
margin: 0 -8px -8px -8px;
|
||||
}
|
||||
.mdl-demo section.section--center .section__text:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(0,0,0,.13);
|
||||
}
|
||||
.mdl-demo .mdl-card .mdl-card__supporting-text > h3:first-child {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.mdl-demo .mdl-layout__tab-panel:not(#overview) {
|
||||
background-color: white;
|
||||
}
|
||||
.mdl-demo #features section {
|
||||
margin-bottom: 72px;
|
||||
}
|
||||
.mdl-demo #features h4, #features h5 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mdl-demo .toc {
|
||||
border-left: 4px solid #C1EEF4;
|
||||
margin: 24px;
|
||||
padding: 0;
|
||||
padding-left: 8px;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mdl-demo .toc h4 {
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.mdl-demo .toc a {
|
||||
color: #4DD0E1;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
display: block;
|
||||
}
|
||||
.mdl-demo .mdl-menu__container {
|
||||
z-index: 99;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,115 @@
|
|||
<!--
|
||||
Copyright 2018 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Kubeflow UI</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:regular,bold,italic,thin,light,bolditalic,black,medium&lang=en">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="static/styles/material.deep_purple-pink.min.css">
|
||||
<link rel="stylesheet" href="static/styles/demo.css">
|
||||
<script src="static/scripts/material.min.js"></script>
|
||||
</head>
|
||||
<body class="mdl-demo mdl-color--grey-100 mdl-color-text--grey-700 mdl-base">
|
||||
<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
|
||||
<header class="mdl-layout__header mdl-layout__header--scroll mdl-color--primary">
|
||||
<div class="mdl-layout--large-screen-only mdl-layout__header-row"></div>
|
||||
<div class="mdl-layout__header-row">
|
||||
<h3>Kubeflow Codelab UI</h3>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mdl-layout__content">
|
||||
<!-- render server connection status -->
|
||||
<section class="section--center mdl-grid mdl-grid--no-spacing mdl-shadow--2dp">
|
||||
<div class="mdl-card mdl-cell mdl-cell--12-col">
|
||||
<div class="mdl-card__supporting-text">
|
||||
<h4>MNIST Model Server</h4>
|
||||
<form action="/">
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label" style="width:250px">
|
||||
<input class="mdl-textfield__input" type="text" id="server-name" name="name" value="{{ args.name }}">
|
||||
<label class="mdl-textfield__label" for="sample1">Model Name</label>
|
||||
</div>
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label" style="width:250px">
|
||||
<input class="mdl-textfield__input" type="text" id="server-address" name="addr" value="{{ args.addr }}">
|
||||
<label class="mdl-textfield__label" for="sample1">Server Address</label>
|
||||
</div>
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label" style="width:100px">
|
||||
<input class="mdl-textfield__input" type="text" name="port"
|
||||
pattern="^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$"
|
||||
id="server-port" value="{{ args.port }}">
|
||||
<label class="mdl-textfield__label" for="sample2">Port</label>
|
||||
<span class="mdl-textfield__error">Input is not a valid port</span>
|
||||
</div>
|
||||
<button class="mdl-button mdl-js-button">Connect</button>
|
||||
</form>
|
||||
{% if connection.success %}
|
||||
<h6><font color="#388E3C">✓ {{ connection.text }}</font></h6>
|
||||
{% else %}
|
||||
<h6><font color="#C62828">❗ {{ connection.text }}</font></h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- if connected to server, render testing results -->
|
||||
{% if output %}
|
||||
<section class="section--center mdl-grid mdl-grid--no-spacing mdl-shadow--2dp">
|
||||
<div class="mdl-card mdl-cell mdl-cell--12-col">
|
||||
<div class="mdl-card__supporting-text">
|
||||
<h4>Test Results</h4>
|
||||
<img src={{ output.img_path }}
|
||||
style="width:128px;height:128px;display:block;margin:auto;">
|
||||
<br><br>
|
||||
<table class="mdl-data-table mdl-js-data-table mdl-shadow--2dp" style="margin:auto;width:40%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mdl-data-table__cell--non-numeric"><b>Truth</b></td>
|
||||
<td><b>{{ output.truth }}</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mdl-data-table__cell--non-numeric"><b>Prediction</b></td>
|
||||
<td><b> {{ output.prediction }}</b></td>
|
||||
</tr>
|
||||
{% for score in output.scores %}
|
||||
<tr>
|
||||
<td class="mdl-data-table__cell--non-numeric">Probability {{ score.index }}:</td>
|
||||
<td>
|
||||
<div id="progressbar{{ score.index }}"
|
||||
class="mdl-progress mdl-js-progress"
|
||||
style="width:120;"></div>
|
||||
<script language = "javascript">
|
||||
document.querySelector('#progressbar{{ score.index }}').addEventListener('mdl-componentupgraded',
|
||||
function() { this.MaterialProgress.setProgress({{ score.val * 100 }}); })
|
||||
</script>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<br><br>
|
||||
<button type="button"
|
||||
class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-color--accent mdl-color-text--accent-contrast"
|
||||
onClick="window.location.reload()" style="margin:auto;display:block">Test Random Image</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue