-
Notifications
You must be signed in to change notification settings - Fork 131
feat/gql-proxy: new approach for GraphQL local scale out #3074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dd17e77
8aca3c6
befbf5a
c991fe4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
{ | ||
"distSpecVersion": "1.1.0", | ||
"storage": { | ||
"rootDirectory": "./workspace/zot/data/mem1", | ||
"dedupe": false | ||
}, | ||
"http": { | ||
"address": "127.0.0.1", | ||
"port": "9000" | ||
}, | ||
"log": { | ||
"level": "debug" | ||
}, | ||
"cluster": { | ||
"members": [ | ||
"127.0.0.1:9000", | ||
"127.0.0.1:9001" | ||
], | ||
"hashKey": "loremipsumdolors" | ||
}, | ||
"extensions": { | ||
"search": { | ||
"cve": { | ||
"updateInterval": "2h" | ||
} | ||
}, | ||
"ui": { | ||
"enable": true | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
{ | ||
"distSpecVersion": "1.1.0", | ||
"storage": { | ||
"rootDirectory": "./workspace/zot/data/mem2", | ||
"dedupe": false | ||
}, | ||
"http": { | ||
"address": "127.0.0.1", | ||
"port": "9001" | ||
}, | ||
"log": { | ||
"level": "debug" | ||
}, | ||
"cluster": { | ||
"members": [ | ||
"127.0.0.1:9000", | ||
"127.0.0.1:9001" | ||
], | ||
"hashKey": "loremipsumdolors" | ||
}, | ||
"extensions": { | ||
"search": { | ||
"cve": { | ||
"updateInterval": "2h" | ||
} | ||
}, | ||
"ui": { | ||
"enable": true | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
global | ||
log /tmp/log local0 | ||
log /tmp/log local1 notice | ||
maxconn 2000 | ||
stats timeout 30s | ||
daemon | ||
|
||
defaults | ||
log global | ||
mode http | ||
option httplog | ||
option dontlognull | ||
timeout connect 5000 | ||
timeout client 50000 | ||
timeout server 50000 | ||
|
||
frontend zot | ||
bind *:8080 | ||
default_backend zot-cluster | ||
|
||
backend zot-cluster | ||
balance roundrobin | ||
cookie SERVER insert indirect nocache | ||
server zot0 127.0.0.1:9000 cookie zot0 | ||
server zot1 127.0.0.1:9001 cookie zot1 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
package api | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"net/http" | ||
|
||
"github.com/gorilla/mux" | ||
|
||
"zotregistry.dev/zot/pkg/api/constants" | ||
"zotregistry.dev/zot/pkg/cluster" | ||
"zotregistry.dev/zot/pkg/proxy" | ||
) | ||
|
||
// ClusterProxy wraps an http.HandlerFunc which requires proxying between zot instances to ensure | ||
// that a given repository only has a single writer and reader for dist-spec operations in a scale-out cluster. | ||
// based on the hash value of the repository name, the request will either be handled locally | ||
// or proxied to another zot member in the cluster to get the data before sending a response to the client. | ||
func ClusterProxy(ctrlr *Controller) func(http.HandlerFunc) http.HandlerFunc { | ||
return func(next http.HandlerFunc) http.HandlerFunc { | ||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { | ||
config := ctrlr.Config | ||
logger := ctrlr.Log | ||
|
||
// if no cluster or single-node cluster, handle locally. | ||
if config.Cluster == nil || len(config.Cluster.Members) == 1 { | ||
next.ServeHTTP(response, request) | ||
|
||
return | ||
} | ||
|
||
// since the handler has been wrapped, it should be possible to get the name | ||
// of the repository from the mux. | ||
vars := mux.Vars(request) | ||
name, ok := vars["name"] | ||
|
||
if !ok || name == "" { | ||
response.WriteHeader(http.StatusNotFound) | ||
|
||
return | ||
} | ||
|
||
// the target member is the only one which should do read/write for the dist-spec APIs | ||
// for the given repository. | ||
targetMemberIndex, targetMember := cluster.ComputeTargetMember(config.Cluster.HashKey, config.Cluster.Members, name) | ||
logger.Debug().Str(constants.RepositoryLogKey, name). | ||
Msg(fmt.Sprintf("target member socket: %s index: %d", targetMember, targetMemberIndex)) | ||
|
||
// if the target member is the same as the local member, the current member should handle the request. | ||
// since the instances have the same config, a quick index lookup is sufficient | ||
if targetMemberIndex == config.Cluster.Proxy.LocalMemberClusterSocketIndex { | ||
logger.Debug().Str(constants.RepositoryLogKey, name).Msg("handling the request locally") | ||
next.ServeHTTP(response, request) | ||
|
||
return | ||
} | ||
|
||
// if the header contains a hop-count, return an error response as there should be no multi-hop | ||
if request.Header.Get(constants.ScaleOutHopCountHeader) != "" { | ||
logger.Fatal().Str("url", request.URL.String()). | ||
Msg("failed to process request - cannot proxy an already proxied request") | ||
|
||
return | ||
} | ||
|
||
logger.Debug().Str(constants.RepositoryLogKey, name).Msg("proxying the request") | ||
|
||
proxyResponse, err := proxy.ProxyHTTPRequest(request.Context(), request, targetMember, ctrlr.Config) | ||
if err != nil { | ||
logger.Error().Err(err).Str(constants.RepositoryLogKey, name).Msg("failed to proxy the request") | ||
http.Error(response, err.Error(), http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
defer func() { | ||
_ = proxyResponse.Body.Close() | ||
}() | ||
|
||
proxy.CopyHeader(response.Header(), proxyResponse.Header) | ||
response.WriteHeader(proxyResponse.StatusCode) | ||
_, _ = io.Copy(response, proxyResponse.Body) | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
package gqlproxy | ||
|
||
import ( | ||
"encoding/json" | ||
"io" | ||
"net/http" | ||
|
||
"zotregistry.dev/zot/pkg/api/config" | ||
"zotregistry.dev/zot/pkg/proxy" | ||
) | ||
|
||
func fanOutGqlHandler(config *config.Config, _ map[string]string, response http.ResponseWriter, request *http.Request) { | ||
// Proxy to all members including self in order to get the data as calling next() won't return the | ||
// aggregated data to this handler. | ||
finalMap := map[string]any{} | ||
|
||
for _, targetMember := range config.Cluster.Members { | ||
proxyResponse, err := proxy.ProxyHTTPRequest(request.Context(), request, targetMember, config) | ||
if err != nil { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to return failure even if just one member fails? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sounds like a good idea. One thought that I had in mind is that instead of swallowing the error, perhaps we could append an error to the Errors list key in the GQL response and send it to the client so there is awareness of some error in the system. The client can choose to ignore the error and use the valid data in the response, or ideally, show both the valid data as well as indicate that there were some errors in processing. With this approach status 206 could be the return status as you've suggested. What do you think? |
||
http.Error(response, "failed to process GQL request", http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
proxyBody, err := io.ReadAll(proxyResponse.Body) | ||
_ = proxyResponse.Body.Close() | ||
|
||
if err != nil { | ||
http.Error(response, "failed to process GQL request", http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
responseResult := map[string]any{} | ||
|
||
err = json.Unmarshal(proxyBody, &responseResult) | ||
if err != nil { | ||
http.Error(response, err.Error(), http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
// perform merge of fields | ||
finalMap = deepMergeMaps(finalMap, responseResult) | ||
} | ||
|
||
prepareAndWriteResponse(finalMap, response) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
package gqlproxy | ||
|
||
import ( | ||
"encoding/json" | ||
"io" | ||
"net/http" | ||
|
||
"zotregistry.dev/zot/pkg/api/config" | ||
"zotregistry.dev/zot/pkg/cluster" | ||
"zotregistry.dev/zot/pkg/proxy" | ||
) | ||
|
||
func repoProxyOnceGqlHandler( | ||
config *config.Config, | ||
args map[string]string, | ||
response http.ResponseWriter, | ||
request *http.Request, | ||
) { | ||
repoName, ok := args["repo"] | ||
if !ok { | ||
// no repo was specified | ||
http.Error(response, "repo name not specified in query", http.StatusBadRequest) | ||
|
||
return | ||
} | ||
|
||
proxyOnceGqlHandler(config, repoName, response, request) | ||
} | ||
|
||
func proxyOnceGqlHandler(config *config.Config, repoName string, response http.ResponseWriter, request *http.Request) { | ||
_, targetMember := cluster.ComputeTargetMember(config.Cluster.HashKey, config.Cluster.Members, repoName) | ||
|
||
proxyResponse, err := proxy.ProxyHTTPRequest(request.Context(), request, targetMember, config) | ||
if err != nil { | ||
http.Error(response, "failed to process GQL request", http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
proxyBody, err := io.ReadAll(proxyResponse.Body) | ||
_ = proxyResponse.Body.Close() | ||
|
||
if err != nil { | ||
http.Error(response, "failed to process GQL request", http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
responseResult := map[string]any{} | ||
|
||
err = json.Unmarshal(proxyBody, &responseResult) | ||
if err != nil { | ||
http.Error(response, err.Error(), http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
prepareAndWriteResponse(responseResult, response) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have to figure out a scheme to append a member path.
Would like to have a single zot configuration that folks don't have to tweak.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree. The reason for this is mostly because I was starting 2 binaries on the same host for development (so I had to change the path and port). For an actual deployment, the config files would be identical.