Skip to content

Commit 2b6dfa5

Browse files
[SPARK-20044][UI] Support Spark UI behind front-end reverse proxy using a path prefix Revert proxy url
### What changes were proposed in this pull request? Allow to run the Spark web UI behind a reverse proxy with URLs prefixed by a context root, like www.mydomain.com/spark. In particular, this allows to access multiple Spark clusters through the same virtual host, only distinguishing them by context root, like www.mydomain.com/cluster1, www.mydomain.com/cluster2, and it allows to run the Spark UI in a common cookie domain (for SSO) with other services. ### Why are the changes needed? This PR is to take over #17455. After changes, Spark allows showing customized prefix URL in all the `href` links of the HTML pages. ### Does this PR introduce _any_ user-facing change? Yes, all the links of UI pages will be contains the value of `spark.ui.reverseProxyUrl` if it is configurated. ### How was this patch tested? New HTML Unit tests in MasterSuite Manual UI testing for master, worker and app UI with an nginx proxy Spark config: ``` spark.ui.port 8080 spark.ui.reverseProxy=true spark.ui.reverseProxyUrl=/path/to/spark/ ``` nginx config: ``` server { listen 9000; set $SPARK_MASTER http://127.0.0.1:8080; # split spark UI path into prefix and local path within master UI location ~ ^(/path/to/spark/) { # strip prefix when forwarding request rewrite /path/to/spark(/.*) $1 break; #rewrite /path/to/spark/ "/" ; # forward to spark master UI proxy_pass $SPARK_MASTER; proxy_intercept_errors on; error_page 301 302 307 = handle_redirects; } location handle_redirects { set $saved_redirect_location '$upstream_http_location'; proxy_pass $saved_redirect_location; } } ``` Closes #29820 from gengliangwang/revertProxyURL. Lead-authored-by: Gengliang Wang <gengliang.wang@databricks.com> Co-authored-by: Oliver Köth <okoeth@de.ibm.com> Signed-off-by: Gengliang Wang <gengliang.wang@databricks.com>
1 parent b8a440f commit 2b6dfa5

7 files changed

Lines changed: 140 additions & 13 deletions

File tree

core/src/main/scala/org/apache/spark/SparkContext.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,9 @@ class SparkContext(config: SparkConf) extends Logging {
570570
_applicationAttemptId = _taskScheduler.applicationAttemptId()
571571
_conf.set("spark.app.id", _applicationId)
572572
if (_conf.get(UI_REVERSE_PROXY)) {
573-
System.setProperty("spark.ui.proxyBase", "/proxy/" + _applicationId)
573+
val proxyUrl = _conf.get(UI_REVERSE_PROXY_URL.key, "").stripSuffix("/") +
574+
"/proxy/" + _applicationId
575+
System.setProperty("spark.ui.proxyBase", proxyUrl)
574576
}
575577
_ui.foreach(_.setAppId(_applicationId))
576578
_env.blockManager.initialize(_applicationId)

core/src/main/scala/org/apache/spark/deploy/master/Master.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,13 @@ private[deploy] class Master(
147147
webUi.bind()
148148
masterWebUiUrl = s"${webUi.scheme}$masterPublicAddress:${webUi.boundPort}"
149149
if (reverseProxy) {
150-
masterWebUiUrl = conf.get(UI_REVERSE_PROXY_URL).orElse(Some(masterWebUiUrl)).get
150+
val uiReverseProxyUrl = conf.get(UI_REVERSE_PROXY_URL).map(_.stripSuffix("/"))
151+
if (uiReverseProxyUrl.nonEmpty) {
152+
System.setProperty("spark.ui.proxyBase", uiReverseProxyUrl.get)
153+
// If the master URL has a path component, it must end with a slash.
154+
// Otherwise the browser generates incorrect relative links
155+
masterWebUiUrl = uiReverseProxyUrl.get + "/"
156+
}
151157
webUi.addProxy()
152158
logInfo(s"Spark Master is acting as a reverse proxy. Master, Workers and " +
153159
s"Applications UIs are available at $masterWebUiUrl")

core/src/main/scala/org/apache/spark/deploy/worker/ExecutorRunner.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ private[deploy] class ExecutorRunner(
171171
// Add webUI log urls
172172
val baseUrl =
173173
if (conf.get(UI_REVERSE_PROXY)) {
174-
s"/proxy/$workerId/logPage/?appId=$appId&executorId=$execId&logType="
174+
conf.get(UI_REVERSE_PROXY_URL.key, "").stripSuffix("/") +
175+
s"/proxy/$workerId/logPage/?appId=$appId&executorId=$execId&logType="
175176
} else {
176177
s"$webUiScheme$publicAddress:$webUiPort/logPage/?appId=$appId&executorId=$execId&logType="
177178
}

core/src/main/scala/org/apache/spark/deploy/worker/Worker.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,14 @@ private[deploy] class Worker(
276276
master = Some(masterRef)
277277
connected = true
278278
if (reverseProxy) {
279-
logInfo(s"WorkerWebUI is available at $activeMasterWebUiUrl/proxy/$workerId")
279+
logInfo("WorkerWebUI is available at %s/proxy/%s".format(
280+
activeMasterWebUiUrl.stripSuffix("/"), workerId))
281+
// if reverseProxyUrl is not set, then we continue to generate relative URLs
282+
// starting with "/" throughout the UI and do not use activeMasterWebUiUrl
283+
val proxyUrl = conf.get(UI_REVERSE_PROXY_URL.key, "").stripSuffix("/")
284+
// In the method `UIUtils.makeHref`, the URL segment "/proxy/$worker_id" will be appended
285+
// after `proxyUrl`, so no need to set the worker ID in the `spark.ui.proxyBase` here.
286+
System.setProperty("spark.ui.proxyBase", proxyUrl)
280287
}
281288
// Cancel any outstanding re-registration attempts because we found a new master
282289
cancelLastRegistrationRetry()

core/src/main/scala/org/apache/spark/ui/UIUtils.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,8 @@ private[spark] object UIUtils extends Logging {
639639
*/
640640
def makeHref(proxy: Boolean, id: String, origHref: String): String = {
641641
if (proxy) {
642-
s"/proxy/$id"
642+
val proxyPrefix = sys.props.getOrElse("spark.ui.proxyBase", "")
643+
proxyPrefix + "/proxy/" + id
643644
} else {
644645
origHref
645646
}

core/src/test/scala/org/apache/spark/deploy/master/MasterSuite.scala

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ class MockExecutorLaunchFailWorker(master: Master, conf: SparkConf = new SparkCo
143143
class MasterSuite extends SparkFunSuite
144144
with Matchers with Eventually with PrivateMethodTester with BeforeAndAfter {
145145

146+
// regex to extract worker links from the master webui HTML
147+
// groups represent URL and worker ID
148+
val WORKER_LINK_RE = """<a href="(.+?)">\s*(worker-.+?)\s*</a>""".r
149+
146150
private var _master: Master = _
147151

148152
after {
@@ -320,10 +324,10 @@ class MasterSuite extends SparkFunSuite
320324
val conf = new SparkConf()
321325
val localCluster = new LocalSparkCluster(2, 2, 512, conf)
322326
localCluster.start()
327+
val masterUrl = s"http://localhost:${localCluster.masterWebUIPort}"
323328
try {
324329
eventually(timeout(5.seconds), interval(100.milliseconds)) {
325-
val json = Source.fromURL(s"http://localhost:${localCluster.masterWebUIPort}/json")
326-
.getLines().mkString("\n")
330+
val json = Source.fromURL(s"$masterUrl/json").getLines().mkString("\n")
327331
val JArray(workers) = (parse(json) \ "workers")
328332
workers.size should be (2)
329333
workers.foreach { workerSummaryJson =>
@@ -332,6 +336,16 @@ class MasterSuite extends SparkFunSuite
332336
.getLines().mkString("\n"))
333337
(workerResponse \ "cores").extract[Int] should be (2)
334338
}
339+
340+
val html = Source.fromURL(s"$masterUrl/").getLines().mkString("\n")
341+
html should include ("Spark Master at spark://")
342+
val workerLinks = (WORKER_LINK_RE findAllMatchIn html).toList
343+
workerLinks.size should be (2)
344+
workerLinks foreach { case WORKER_LINK_RE(workerUrl, workerId) =>
345+
val workerHtml = Source.fromURL(workerUrl).getLines().mkString("\n")
346+
workerHtml should include ("Spark Worker at")
347+
workerHtml should include ("Running Executors (0)")
348+
}
335349
}
336350
} finally {
337351
localCluster.stop()
@@ -340,31 +354,106 @@ class MasterSuite extends SparkFunSuite
340354

341355
test("master/worker web ui available with reverseProxy") {
342356
implicit val formats = org.json4s.DefaultFormats
343-
val reverseProxyUrl = "http://localhost:8080"
357+
val conf = new SparkConf()
358+
conf.set(UI_REVERSE_PROXY, true)
359+
val localCluster = new LocalSparkCluster(2, 2, 512, conf)
360+
localCluster.start()
361+
val masterUrl = s"http://localhost:${localCluster.masterWebUIPort}"
362+
try {
363+
eventually(timeout(5.seconds), interval(100.milliseconds)) {
364+
val json = Source.fromURL(s"$masterUrl/json")
365+
.getLines().mkString("\n")
366+
val JArray(workers) = (parse(json) \ "workers")
367+
workers.size should be (2)
368+
workers.foreach { workerSummaryJson =>
369+
// the webuiaddress intentionally points to the local web ui.
370+
// explicitly construct reverse proxy url targeting the master
371+
val JString(workerId) = workerSummaryJson \ "id"
372+
val url = s"$masterUrl/proxy/${workerId}/json"
373+
val workerResponse = parse(Source.fromURL(url).getLines().mkString("\n"))
374+
(workerResponse \ "cores").extract[Int] should be (2)
375+
}
376+
377+
val html = Source.fromURL(s"$masterUrl/").getLines().mkString("\n")
378+
html should include ("Spark Master at spark://")
379+
html should include ("""href="/static""")
380+
html should include ("""src="/static""")
381+
verifyWorkerUI(html, masterUrl)
382+
}
383+
} finally {
384+
localCluster.stop()
385+
System.getProperties().remove("spark.ui.proxyBase")
386+
}
387+
}
388+
389+
test("master/worker web ui available behind front-end reverseProxy") {
390+
implicit val formats = org.json4s.DefaultFormats
391+
val reverseProxyUrl = "http://proxyhost:8080/path/to/spark"
344392
val conf = new SparkConf()
345393
conf.set(UI_REVERSE_PROXY, true)
346394
conf.set(UI_REVERSE_PROXY_URL, reverseProxyUrl)
347395
val localCluster = new LocalSparkCluster(2, 2, 512, conf)
348396
localCluster.start()
397+
val masterUrl = s"http://localhost:${localCluster.masterWebUIPort}"
349398
try {
350399
eventually(timeout(5.seconds), interval(100.milliseconds)) {
351-
val json = Source.fromURL(s"http://localhost:${localCluster.masterWebUIPort}/json")
400+
val json = Source.fromURL(s"$masterUrl/json")
352401
.getLines().mkString("\n")
353402
val JArray(workers) = (parse(json) \ "workers")
354403
workers.size should be (2)
355404
workers.foreach { workerSummaryJson =>
405+
// the webuiaddress intentionally points to the local web ui.
406+
// explicitly construct reverse proxy url targeting the master
356407
val JString(workerId) = workerSummaryJson \ "id"
357-
val url = s"http://localhost:${localCluster.masterWebUIPort}/proxy/${workerId}/json"
408+
val url = s"$masterUrl/proxy/${workerId}/json"
358409
val workerResponse = parse(Source.fromURL(url).getLines().mkString("\n"))
359410
(workerResponse \ "cores").extract[Int] should be (2)
360-
(workerResponse \ "masterwebuiurl").extract[String] should be (reverseProxyUrl)
411+
(workerResponse \ "masterwebuiurl").extract[String] should be (reverseProxyUrl + "/")
361412
}
413+
414+
// with LocalCluster, we have masters and workers in the same JVM, each overwriting
415+
// system property spark.ui.proxyBase.
416+
// so we need to manage this property explicitly for test
417+
System.getProperty("spark.ui.proxyBase") should startWith
418+
(s"$reverseProxyUrl/proxy/worker-")
419+
System.setProperty("spark.ui.proxyBase", reverseProxyUrl)
420+
val html = Source.fromURL(s"$masterUrl/").getLines().mkString("\n")
421+
html should include ("Spark Master at spark://")
422+
verifyStaticResourcesServedByProxy(html, reverseProxyUrl)
423+
verifyWorkerUI(html, masterUrl, reverseProxyUrl)
362424
}
363425
} finally {
364426
localCluster.stop()
427+
System.getProperties().remove("spark.ui.proxyBase")
428+
}
429+
}
430+
431+
private def verifyWorkerUI(masterHtml: String, masterUrl: String,
432+
reverseProxyUrl: String = ""): Unit = {
433+
val workerLinks = (WORKER_LINK_RE findAllMatchIn masterHtml).toList
434+
workerLinks.size should be (2)
435+
workerLinks foreach {
436+
case WORKER_LINK_RE(workerUrl, workerId) =>
437+
workerUrl should be (s"$reverseProxyUrl/proxy/$workerId")
438+
// there is no real front-end proxy as defined in $reverseProxyUrl
439+
// construct url directly targeting the master
440+
val url = s"$masterUrl/proxy/$workerId/"
441+
System.setProperty("spark.ui.proxyBase", workerUrl)
442+
val workerHtml = Source.fromURL(url).getLines().mkString("\n")
443+
workerHtml should include ("Spark Worker at")
444+
workerHtml should include ("Running Executors (0)")
445+
verifyStaticResourcesServedByProxy(workerHtml, workerUrl)
446+
case _ => fail // make sure we don't accidentially skip the tests
365447
}
366448
}
367449

450+
private def verifyStaticResourcesServedByProxy(html: String, proxyUrl: String): Unit = {
451+
html should not include ("""href="/static""")
452+
html should include (s"""href="$proxyUrl/static""")
453+
html should not include ("""src="/static""")
454+
html should include (s"""src="$proxyUrl/static""")
455+
}
456+
368457
test("basic scheduling - spread out") {
369458
basicScheduling(spreadOut = true)
370459
}

docs/configuration.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,8 +1193,29 @@ Apart from these, the following properties are also available, and may be useful
11931193
<td><code>spark.ui.reverseProxyUrl</code></td>
11941194
<td></td>
11951195
<td>
1196-
This is the URL where your proxy is running. This URL is for proxy which is running in front of Spark Master. This is useful when running proxy for authentication e.g. OAuth proxy. Make sure this is a complete URL including scheme (http/https) and port to reach your proxy.
1197-
</td>
1196+
If the Spark UI should be served through another front-end reverse proxy, this is the URL
1197+
for accessing the Spark master UI through that reverse proxy.
1198+
This is useful when running proxy for authentication e.g. an OAuth proxy. The URL may contain
1199+
a path prefix, like <code>http://mydomain.com/path/to/spark/</code>, allowing you to serve the
1200+
UI for multiple Spark clusters and other web applications through the same virtual host and
1201+
port.
1202+
Normally, this should be an absolute URL including scheme (http/https), host and port.
1203+
It is possible to specify a relative URL starting with "/" here. In this case, all URLs
1204+
generated by the Spark UI and Spark REST APIs will be server-relative links -- this will still
1205+
work, as the entire Spark UI is served through the same host and port.
1206+
<br/>The setting affects link generation in the Spark UI, but the front-end reverse proxy
1207+
is responsible for
1208+
<ul>
1209+
<li>stripping a path prefix before forwarding the request,</li>
1210+
<li>rewriting redirects which point directly to the Spark master,</li>
1211+
<li>redirecting access from <code>http://mydomain.com/path/to/spark</code> to
1212+
<code>http://mydomain.com/path/to/spark/</code> (trailing slash after path prefix); otherwise
1213+
relative links on the master page do not work correctly.</li>
1214+
</ul>
1215+
This setting affects all the workers and application UIs running in the cluster and must be set
1216+
identically on all the workers, drivers and masters. In is only effective when
1217+
<code>spark.ui.reverseProxy</code> is turned on. This setting is not needed when the Spark
1218+
master web UI is directly reachable. </td>
11981219
<td>2.1.0</td>
11991220
</tr>
12001221
<tr>

0 commit comments

Comments
 (0)