Support redis cluster

- Added `--redis-cluster-nodes` flag
- Display cluster information in redis info page
This commit is contained in:
Ken Hibino 2021-09-06 06:32:23 -07:00
parent 008215566a
commit ce5c86eea5
6 changed files with 337 additions and 118 deletions

91
main.go
View File

@ -10,6 +10,7 @@ import (
"log" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
@ -20,13 +21,14 @@ import (
// Command-line flags // Command-line flags
var ( var (
flagPort int flagPort int
flagRedisAddr string flagRedisAddr string
flagRedisDB int flagRedisDB int
flagRedisPassword string flagRedisPassword string
flagRedisTLS string flagRedisTLS string
flagRedisURL string flagRedisURL string
flagRedisInsecureTLS bool flagRedisInsecureTLS bool
flagRedisClusterNodes string
) )
func init() { func init() {
@ -36,7 +38,8 @@ func init() {
flag.StringVar(&flagRedisPassword, "redis-password", "", "password to use when connecting to redis server") flag.StringVar(&flagRedisPassword, "redis-password", "", "password to use when connecting to redis server")
flag.StringVar(&flagRedisTLS, "redis-tls", "", "server name for TLS validation used when connecting to redis server") flag.StringVar(&flagRedisTLS, "redis-tls", "", "server name for TLS validation used when connecting to redis server")
flag.StringVar(&flagRedisURL, "redis-url", "", "URL to redis server") flag.StringVar(&flagRedisURL, "redis-url", "", "URL to redis server")
flag.BoolVar(&flagRedisInsecureTLS, "redis-insecure-tls", false, "Disable TLS certificate host checks") flag.BoolVar(&flagRedisInsecureTLS, "redis-insecure-tls", false, "disable TLS certificate host checks")
flag.StringVar(&flagRedisClusterNodes, "redis-cluster-nodes", "", "comma separated list of host:port addresses of cluster nodes")
} }
// staticFileServer implements the http.Handler interface, so we can use it // staticFileServer implements the http.Handler interface, so we can use it
@ -88,20 +91,26 @@ func (srv *staticFileServer) indexFilePath() string {
return filepath.Join(srv.staticDirPath, srv.indexFileName) return filepath.Join(srv.staticDirPath, srv.indexFileName)
} }
func getRedisOptionsFromFlags() (*redis.Options, error) { func getRedisOptionsFromFlags() (*redis.UniversalOptions, error) {
var err error var opts redis.UniversalOptions
var opts *redis.Options
if flagRedisURL != "" { if flagRedisClusterNodes != "" {
opts, err = redis.ParseURL(flagRedisURL) opts.Addrs = strings.Split(flagRedisClusterNodes, ",")
if err != nil { opts.Password = flagRedisPassword
return nil, err
}
} else { } else {
opts = &redis.Options{ if flagRedisURL != "" {
Addr: flagRedisAddr, res, err := redis.ParseURL(flagRedisURL)
DB: flagRedisDB, if err != nil {
Password: flagRedisPassword, return nil, err
}
opts.Addrs = append(opts.Addrs, res.Addr)
opts.DB = res.DB
opts.Password = res.Password
} else {
opts.Addrs = []string{flagRedisAddr}
opts.DB = flagRedisDB
opts.Password = flagRedisPassword
} }
} }
@ -114,7 +123,7 @@ func getRedisOptionsFromFlags() (*redis.Options, error) {
} }
opts.TLSConfig.InsecureSkipVerify = true opts.TLSConfig.InsecureSkipVerify = true
} }
return opts, nil return &opts, nil
} }
//go:embed ui/build/* //go:embed ui/build/*
@ -128,16 +137,34 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
inspector := asynq.NewInspector(asynq.RedisClientOpt{ useRedisCluster := flagRedisClusterNodes != ""
Addr: opts.Addr,
DB: opts.DB, var redisConnOpt asynq.RedisConnOpt
Password: opts.Password, if useRedisCluster {
TLSConfig: opts.TLSConfig, redisConnOpt = asynq.RedisClusterClientOpt{
}) Addrs: opts.Addrs,
Password: opts.Password,
TLSConfig: opts.TLSConfig,
}
} else {
redisConnOpt = asynq.RedisClientOpt{
Addr: opts.Addrs[0],
DB: opts.DB,
Password: opts.Password,
TLSConfig: opts.TLSConfig,
}
}
inspector := asynq.NewInspector(redisConnOpt)
defer inspector.Close() defer inspector.Close()
rdb := redis.NewClient(opts) var redisClient redis.UniversalClient
defer rdb.Close() if useRedisCluster {
redisClient = redis.NewClusterClient(opts.Cluster())
} else {
redisClient = redis.NewClient(opts.Simple())
}
defer redisClient.Close()
router := mux.NewRouter() router := mux.NewRouter()
router.Use(loggingMiddleware) router.Use(loggingMiddleware)
@ -207,7 +234,11 @@ func main() {
api.HandleFunc("/scheduler_entries/{entry_id}/enqueue_events", newListSchedulerEnqueueEventsHandlerFunc(inspector)).Methods("GET") api.HandleFunc("/scheduler_entries/{entry_id}/enqueue_events", newListSchedulerEnqueueEventsHandlerFunc(inspector)).Methods("GET")
// Redis info endpoint. // Redis info endpoint.
api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(rdb)).Methods("GET") if useRedisCluster {
api.HandleFunc("/redis_info", newRedisClusterInfoHandlerFunc(redisClient.(*redis.ClusterClient), inspector)).Methods("GET")
} else {
api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(redisClient.(*redis.Client))).Methods("GET")
}
fs := &staticFileServer{ fs := &staticFileServer{
contents: staticContents, contents: staticContents,

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"github.com/hibiken/asynq"
) )
// **************************************************************************** // ****************************************************************************
@ -18,21 +19,86 @@ type RedisInfoResponse struct {
Addr string `json:"address"` Addr string `json:"address"`
Info map[string]string `json:"info"` Info map[string]string `json:"info"`
RawInfo string `json:"raw_info"` RawInfo string `json:"raw_info"`
Cluster bool `json:"cluster"`
// Following fields are only set when connected to redis cluster.
RawClusterNodes string `json:"raw_cluster_nodes"`
QueueLocations []*QueueLocationInfo `json:"queue_locations"`
} }
func newRedisInfoHandlerFunc(rdb *redis.Client) http.HandlerFunc { type QueueLocationInfo struct {
Queue string `json:"queue"` // queue name
KeySlot int64 `json:"keyslot"` // cluster key slot for the queue
Nodes []string `json:"nodes"` // list of cluster node addresses
}
func newRedisInfoHandlerFunc(client *redis.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()
res, err := rdb.Info(ctx).Result() res, err := client.Info(ctx).Result()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
info := parseRedisInfo(res) info := parseRedisInfo(res)
resp := RedisInfoResponse{ resp := RedisInfoResponse{
Addr: rdb.Options().Addr, Addr: client.Options().Addr,
Info: info, Info: info,
RawInfo: res, RawInfo: res,
Cluster: false,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func newRedisClusterInfoHandlerFunc(client *redis.ClusterClient, inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
rawClusterInfo, err := client.ClusterInfo(ctx).Result()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
info := parseRedisInfo(rawClusterInfo)
rawClusterNodes, err := client.ClusterNodes(ctx).Result()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
queues, err := inspector.Queues()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var queueLocations []*QueueLocationInfo
for _, qname := range queues {
q := QueueLocationInfo{Queue: qname}
q.KeySlot, err = inspector.ClusterKeySlot(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
nodes, err := inspector.ClusterNodes(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, n := range nodes {
q.Nodes = append(q.Nodes, n.Addr)
}
queueLocations = append(queueLocations, &q)
}
resp := RedisInfoResponse{
Addr: strings.Join(client.Options().Addrs, ","),
Info: info,
RawInfo: rawClusterInfo,
Cluster: true,
RawClusterNodes: rawClusterNodes,
QueueLocations: queueLocations,
} }
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -80,6 +80,18 @@ export interface RedisInfoResponse {
address: string; address: string;
info: RedisInfo; info: RedisInfo;
raw_info: string; raw_info: string;
cluster: boolean;
// following fields are set only when cluster=true
raw_cluster_nodes: string;
queue_locations: QueueLocation[] | null;
}
// Describes location of a queue in cluster.
export interface QueueLocation {
queue: string; // queue name
keyslot: number; // cluster keyslot
nodes: string[]; // node addresses
} }
// Return value from redis INFO command. // Return value from redis INFO command.

View File

@ -0,0 +1,48 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import { QueueLocation } from "../api";
const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
}));
interface Props {
queueLocations: QueueLocation[];
}
export default function QueueLocationTable(props: Props) {
const classes = useStyles();
return (
<TableContainer>
<Table className={classes.table} aria-label="queue location table">
<TableHead>
<TableRow>
<TableCell>Queue</TableCell>
<TableCell>KeySlot</TableCell>
<TableCell>Node Addresses</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.queueLocations.map((loc) => (
<TableRow key={loc.queue}>
<TableCell component="th" scope="row">
{loc.queue}
</TableCell>
<TableCell>{loc.keyslot}</TableCell>
<TableCell>{loc.nodes.join(", ")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@ -4,7 +4,7 @@ import {
GET_REDIS_INFO_SUCCESS, GET_REDIS_INFO_SUCCESS,
RedisInfoActionTypes, RedisInfoActionTypes,
} from "../actions/redisInfoActions"; } from "../actions/redisInfoActions";
import { RedisInfo } from "../api"; import { QueueLocation, RedisInfo } from "../api";
interface RedisInfoState { interface RedisInfoState {
loading: boolean; loading: boolean;
@ -12,6 +12,9 @@ interface RedisInfoState {
address: string; address: string;
data: RedisInfo | null; data: RedisInfo | null;
rawData: string | null; rawData: string | null;
cluster: boolean;
rawClusterNodes: string | null;
queueLocations: QueueLocation[] | null;
} }
const initialState: RedisInfoState = { const initialState: RedisInfoState = {
@ -20,6 +23,9 @@ const initialState: RedisInfoState = {
address: "", address: "",
data: null, data: null,
rawData: null, rawData: null,
cluster: false,
rawClusterNodes: null,
queueLocations: null,
}; };
export default function redisInfoReducer( export default function redisInfoReducer(
@ -47,6 +53,9 @@ export default function redisInfoReducer(
address: action.payload.address, address: action.payload.address,
data: action.payload.info, data: action.payload.info,
rawData: action.payload.raw_info, rawData: action.payload.raw_info,
cluster: action.payload.cluster,
rawClusterNodes: action.payload.raw_cluster_nodes,
queueLocations: action.payload.queue_locations,
}; };
default: default:

View File

@ -13,6 +13,9 @@ import { getRedisInfoAsync } from "../actions/redisInfoActions";
import { usePolling } from "../hooks"; import { usePolling } from "../hooks";
import { AppState } from "../store"; import { AppState } from "../store";
import { timeAgoUnix } from "../utils"; import { timeAgoUnix } from "../utils";
import { RedisInfo } from "../api";
import QueueLocationTable from "../components/QueueLocationTable";
import Link from "@material-ui/core/Link";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
@ -28,6 +31,9 @@ function mapStateToProps(state: AppState) {
redisInfo: state.redis.data, redisInfo: state.redis.data,
redisAddress: state.redis.address, redisAddress: state.redis.address,
redisInfoRaw: state.redis.rawData, redisInfoRaw: state.redis.rawData,
redisClusterEnabled: state.redis.cluster,
redisClusterNodesRaw: state.redis.rawClusterNodes,
queueLocations: state.redis.queueLocations,
pollInterval: state.settings.pollInterval, pollInterval: state.settings.pollInterval,
themePreference: state.settings.themePreference, themePreference: state.settings.themePreference,
}; };
@ -38,7 +44,15 @@ type Props = ConnectedProps<typeof connector>;
function RedisInfoView(props: Props) { function RedisInfoView(props: Props) {
const classes = useStyles(); const classes = useStyles();
const { pollInterval, getRedisInfoAsync, redisInfo, redisInfoRaw } = props; const {
pollInterval,
getRedisInfoAsync,
redisInfo,
redisInfoRaw,
redisClusterEnabled,
redisClusterNodesRaw,
queueLocations,
} = props;
usePolling(getRedisInfoAsync, pollInterval); usePolling(getRedisInfoAsync, pollInterval);
// Metrics to show // Metrics to show
@ -56,101 +70,60 @@ function RedisInfoView(props: Props) {
<> <>
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h5" color="textPrimary"> <Typography variant="h5" color="textPrimary">
Redis Info {redisClusterEnabled ? "Redis Cluster Info" : "Redis Info"}
</Typography>
<Typography variant="subtitle1" color="textSecondary">
Connected to: {props.redisAddress}
</Typography> </Typography>
{!redisClusterEnabled && (
<Typography variant="subtitle1" color="textSecondary">
Connected to: {props.redisAddress}
</Typography>
)}
</Grid> </Grid>
{redisInfo !== null && ( {queueLocations && queueLocations.length > 0 && (
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Queue Location in Cluster
</Typography>
<QueueLocationTable queueLocations={queueLocations} />
</Grid>
)}
{redisClusterNodesRaw && (
<> <>
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h6" color="textSecondary"> <Typography variant="h6" color="textSecondary">
Server <Link
href="https://redis.io/commands/cluster-nodes"
target="_"
>
CLUSTER NODES
</Link>{" "}
Command Output
</Typography> </Typography>
<SyntaxHighlighter language="yaml">
{redisClusterNodesRaw}
</SyntaxHighlighter>
</Grid> </Grid>
<Grid item xs={3}>
<MetricCard
title="Version"
content={redisInfo.redis_version}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Uptime"
content={`${redisInfo.uptime_in_days} days`}
/>
</Grid>
<Grid item xs={6} />
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Memory
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Used Memory"
content={redisInfo.used_memory_human}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Peak Memory Used"
content={redisInfo.used_memory_peak_human}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Memory Fragmentation Ratio"
content={redisInfo.mem_fragmentation_ratio}
/>
</Grid>
<Grid item xs={3} />
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Connections
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Connected Clients"
content={redisInfo.connected_clients}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Connected Replicas"
content={redisInfo.connected_slaves}
/>
</Grid>
<Grid item xs={6} />
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Persistence
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Last Save to Disk"
content={timeAgoUnix(
parseInt(redisInfo.rdb_last_save_time)
)}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Number of Changes Since Last Dump"
content={redisInfo.rdb_changes_since_last_save}
/>
</Grid>
<Grid item xs={6} />
</> </>
)} )}
{redisInfoRaw !== null && ( {redisInfo && !redisClusterEnabled && (
<RedisMetricCards redisInfo={redisInfo} />
)}
{redisInfoRaw && (
<> <>
<Grid item xs={6}> <Grid item xs={6}>
<Typography variant="h6" color="textSecondary"> <Typography variant="h6" color="textSecondary">
INFO Command Output {redisClusterEnabled ? (
<Link
href="https://redis.io/commands/cluster-info"
target="_"
>
CLUSTER INFO
</Link>
) : (
<Link href="https://redis.io/commands/info" target="_">
INFO
</Link>
)}{" "}
Command Output
</Typography> </Typography>
<SyntaxHighlighter language="yaml"> <SyntaxHighlighter language="yaml">
{redisInfoRaw} {redisInfoRaw}
@ -173,6 +146,86 @@ function RedisInfoView(props: Props) {
); );
} }
function RedisMetricCards(props: { redisInfo: RedisInfo }) {
const { redisInfo } = props;
return (
<>
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Server
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard title="Version" content={redisInfo.redis_version} />
</Grid>
<Grid item xs={3}>
<MetricCard
title="Uptime"
content={`${redisInfo.uptime_in_days} days`}
/>
</Grid>
<Grid item xs={6} />
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Memory
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard title="Used Memory" content={redisInfo.used_memory_human} />
</Grid>
<Grid item xs={3}>
<MetricCard
title="Peak Memory Used"
content={redisInfo.used_memory_peak_human}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Memory Fragmentation Ratio"
content={redisInfo.mem_fragmentation_ratio}
/>
</Grid>
<Grid item xs={3} />
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Connections
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Connected Clients"
content={redisInfo.connected_clients}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Connected Replicas"
content={redisInfo.connected_slaves}
/>
</Grid>
<Grid item xs={6} />
<Grid item xs={12}>
<Typography variant="h6" color="textSecondary">
Persistence
</Typography>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Last Save to Disk"
content={timeAgoUnix(parseInt(redisInfo.rdb_last_save_time))}
/>
</Grid>
<Grid item xs={3}>
<MetricCard
title="Number of Changes Since Last Dump"
content={redisInfo.rdb_changes_since_last_save}
/>
</Grid>
<Grid item xs={6} />
</>
);
}
interface MetricCardProps { interface MetricCardProps {
title: string; title: string;
content: string; content: string;