Selenosis (Selenoid K8s)

Abdullah Sarıkaya
Sahibinden Technology
7 min readApr 30, 2024

Selenium testlerinin Selenium Grid veya Selenoid uygulamaları yerine Kubernetes ortamında çalışan Selenosis uygulamasını incelediğim ve örnekle deneyimlerimi paylaştığım bir makale hazırladım.

Uygulama OpenShift üzerinde gerçekleştirildi. Örnek uygulamada Java Selenium 4.18.1 kullanıldı.

Makalede kubernetes veya openshift kurulumu anlatmayacağım. Aşağıdaki link’ten minikube kurulumu yapabilir ve kubernetes dashboard’a ulaşabilirsiniz.

https://minikube.sigs.k8s.io/docs/start/

Öncelikle Selenium Grid, Selenoid ve Selenosis nasıl kullanılıyor bunlardan bahsedeceğim.

Selenium Grid

Yukarıdaki resimde olduğu gibi ister fiziksel makinaya ister docker ile bir hub ve ihtiyaca kadar node ayaklandırarak kullanılmaktadır.

Selenium Grid 3 veya Selenium Grid 4 k8s ortamında da benzer şekilde çalışmaktadır. Burada vurgulamak istediğim; ör: 500 adet node’a ihtiyacınız varsa, bu 500 fiziksel makine, docker veya pod’un ayakta bekliyor olması gerekir. Buda enerji maliyeti anlamına gelir.

Selenoid

K8s’in henüz yaygınlaşmadığı docker’ın popüler olduğu zamanlarda geliştirildi. Selenium Grid 3’ e göre 2 büyük artısı vardı. Hem Selenoid ui ile anlık vnc ile erişim (Selenium Grid 4 ile bu özellik Selenium Grid’e geldi) hem de istek gittiği anda docker ile node oluşması. Anlık node oluşturabildiği için sadece 1 hub’ın ayakta olması yeterli oluyor ve enerji tüketimini azaltıyor. Ayrıca Selenium Grid Java ile Selenoid Go ile çalışıyor. Buda daha düşük enerji ve daha hızlı olmasını sağlıyor.

Ancak bu yapı sadece docker ile çalışabilmektedir.

Selenosis

Selenoid’in k8s ortamında scale eden open source bir projedir. Selenoid node’u docker ile ayaklandırırken Selenosis node’u pod ile scale etmektedir. Selenoid’e benzer şekilde Go dilinde yazılmıştır.

Selenosis Topoloji

Yukarıdaki resimde Topoloji yer almaktadır.

İlk olarak Selenium RemoteWebDriver oluşurken, Selenosis Service’e istek atar. Selenosis Service, n tane Selenosis pod’undan birine gidip bu isteği iletir. Selenosis, gelen istekte capabilities de browserName veya browserVersion varsa browser.json bakar ve eşleşen değerin Selenoid image’i ile Seleniferous’u bir pod’da create eder. Eşleşme olmazsa browser.json daki default değeri baz alır.

Örneğin bir url’e gitmek istesin. Bu durumda Selenosis, Seleniferous’a bu isteği proxy eder. Seleniferous da aynı şekilde browser’a proxy eder. “browser.close()” komutu gelinceye kadar bu şekilde devam eder. Seleniferous, Selenosis ile browser arasında iletişimi sağlayan bir uygulamadır.

Close işleminde de Selenosis, kubernetes’e pod delete komutu gönderir ve kapanır.

Selenosis Kurulumu

Alan adı oluşturma

apiVersion: v1
kind: Namespace
metadata:
name: selenosis

Role oluşturma

Rules “*” olarak verilmiştir. Kısıt eklenebilir. Global konfigürasyonla ile bu rule’u verme yetkiniz olamayabilir. Bu durumda Pod create, Resource update yetkileri olan ruleların eklenmesi gerekir.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: selenosis-cluster-role
namespace: selenosis
rules:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: selenosis-cluster-role-binding
namespace: selenosis
roleRef:
kind: ClusterRole
name: selenosis-cluster-role
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
namespace: selenosis
name: default

Service oluşturma

a. Selenosis Service

apiVersion: v1
kind: Service
metadata:
name: selenosis
namespace: selenosis
annotations:
haproxy.router.openshift.io/timeout: 20000s
openshift.io/host.generated: 'true'
spec:
ports:
- name: selenium
port: 4444
protocol: TCP
targetPort: 4444
selector:
app: selenosis
type: ClusterIP

b. Seleniferous Service

Browser pod’u oluştuktan sonra Selenosis ve Browser arası erişim için gereklidir.

apiVersion: v1
kind: Service
metadata:
name: seleniferous
namespace: selenosis
spec:
selector:
type: browser
clusterIP: None
publishNotReadyAddresses: true

ConfigMap oluşturma

kind: ConfigMap
apiVersion: v1
metadata:
name: selenosis-config
namespace: selenosis
data:
browsers.json: |-
{
"chrome": {
"defaultVersion": "123.0",
"path": "/",
"spec": {
"env": [
{
"name": "SCREEN_RESOLUTION",
"value": "1920x1080x24"
},
{
"name": "ENABLE_VNC",
"value": "true"
}
],
"tolerations": [
{
"key": "virtual-kubelet.io/provider",
"operator": "Exists"
}
]
},
"kernelCaps": ["SYS_ADMIN"],
"versions": {
"123.0": {
"image": "selenoid/vnc_chrome:123.0",
"tmpfs": {"/tmp":"size=512m", "/var":"size=128m"},
"shmSize" : 2147483648
}
},
"versions": {
"122.0": {
"image": "selenoid/vnc_chrome:122.0",
"tmpfs": {"/tmp":"size=512m", "/var":"size=128m"},
"shmSize" : 2147483648
}
}
}
}

Deployment oluşturma

Burada parametre ile bazı konfigürasyonlar yapılmaktadır.

https://github.com/alcounit/selenosis buradaki readme bölümünden inceleyebilirsiniz.

Aşağıda 2 parametre hakkında biraz detaya değinmek isterim.

  • — bowser-limit: Browser limitinizi mutlaka talep ettiğinizi üzerinde bir değer veriniz. Test koşum esnasında başka biri daha koşum yapıyorsa testleriniz kırılabilir. Aslında bu limit namespace’teki pod limitidir. Limite ulaştığında pod oluşturamayacağı için browser oluşturulamadı hatası alırsınız.
  • — session-retry-count: Pod ayağa kalkmazsa count kadar tekrar dener.

Normalde image olarak “alcounit/selenosis:v1.0.5” kullanıyor. Bu versiyonu kullandığınızda bazı sorunlar yaşayabilirsiniz.

https://github.com/alcounit/selenosis burada readme bölümünün en sonunda belirtildiği gibi bazen “no such host” içeren hata alabiliyor. Çözüm için Kubernetes CoreDns’de editleme yapılmasını belirtiyor. Ancak birçok uygulamanın bulunduğu bir platformda burada bir değişiklik yapılması tavsiye edilmiyor.

Bu hata, proxy ederken veya create ederken 2 durumda da alınabiliyor. Bu sebepten alınan create hatalarda retry’a giremiyor. Ayrıca proxy ederken olabilecek hatalara karşı da bir retry mekanizması bulunmuyor.

Aşağıdaki deployment’ta image olarak “abdullahsrky/selenosis:latest” verdim. Yukarıda bahsettiğim sorunları fixlediğim pr ilgili repoda ve image docker hub’ta bulunmaktadır. https://github.com/alcounit/selenosis/pull/59 linkinden pr’ı inceleyebilirsiniz.

Count değerini 10 verdim, genel de sorun yaşanırsa 6'ya kadar olan denemede sorunu aşıyor. 10 değeri yetmezse rahatlıkla arttırabilirsiniz. Saniyede 2000 defa deneyebildiğinden performans kaybına neden olmuyor. Çok fazla browser açık olduğunda çok nadiren bu denemeler 2000'leri bulabildiği de oluyor. Bu count değerini sisteminiz üzerinde deneyerek optimum değeri verebilirsiniz.

apiVersion: apps/v1
kind: Deployment
metadata:
name: selenosis
namespace: selenosis
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
replicas: 10
selector:
matchLabels:
app: selenosis
template:
metadata:
labels:
app: selenosis
selenosis.app.type: worker
namespace: selenosis
spec:
containers:
- args: [
"/selenosis",
"--browsers-config", "./config/browsers.json",
"--namespace", "selenosis",
"--service-name", "seleniferous",
"--browser-limit", "2000",
"--session-retry-count", "10",
"--session-wait-timeout", 300s,
"--browser-wait-timeout", 300s,
"--graceful-shutdown-timeout", 300s,
"--proxy-image", "alcounit/seleniferous:latest"]
image: abdullahsrky/selenosis:latest
name: selenosis
resources:
limits:
cpu: '1'
memory: 1Gi
requests:
cpu: 250m
memory: 256Mi
ports:
- containerPort: 4444
name: selenium
protocol: TCP
volumeMounts:
- mountPath: ./config
name: browsers-config
imagePullPolicy: IfNotPresent
readinessProbe:
httpGet:
path: /healthz
port: 4444
periodSeconds: 2
initialDelaySeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: 4444
periodSeconds: 2
initialDelaySeconds: 3
volumes:
- name: browsers-config
configMap:
name: selenosis-config

HPA oluşturma (Zorunlu değil)

Enerji tüketimini azaltmak için Selenosis’i scale etmede kullanılmaktadır. Makalenin devamında vereceğim örnekteki gibi çok fazla istek geldiğinde scale ediyor. Ancak podlar ready oluncaya kadar geçen sürede istekler hali hazırda ayakta olan podlara yönlendirildiğinden, olan podların çökmesine sebep olabiliyor. Bu nedenle kullanılacaksa doğru konfigüre edilmelidir.

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
name: selenosis
namespace: selenosis
spec:
maxReplicas: 10
minReplicas: 2
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: selenosis
targetCPUUtilizationPercentage: 50

Selenoid-Ui Deployment ve Service (Zorunlu değil)

apiVersion: apps/v1
kind: Deployment
metadata:
name: selenoid-ui
namespace: selenosis
spec:
replicas: 1
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: selenoid-ui
template:
metadata:
labels:
app: selenoid-ui
selenosis.app.type: worker
namespace: selenosis
spec:
containers:
- args: ["--status-uri", "http://localhost:4444", "-webdriver-uri", "http://selenosis:4444"]
name: selenoid-ui
imagePullPolicy: IfNotPresent
image: 'aerokube/selenoid-ui:latest'
resources:
limits:
cpu: "0.25"
memory: "64Mi"
requests:
cpu: "0.05"
memory: "64Mi"
ports:
- containerPort: 8080
name: http
protocol: TCP
- args: ["--port", ":4444", "--selenosis-url", "http://selenosis:4444"]
name: selenoid-ui-adapter
imagePullPolicy: IfNotPresent
image: 'alcounit/adaptee:latest'
resources:
limits:
cpu: "0.25"
memory: "64Mi"
requests:
cpu: "0.05"
memory: "64Mi"
restartPolicy: Always
terminationGracePeriodSeconds: 0
---
apiVersion: v1
kind: Service
metadata:
name: selenoid-ui
namespace: selenosis
spec:
externalTrafficPolicy: Cluster
ports:
- name: selenoid-ui
port: 8080
targetPort: 8080
nodePort: 32000
protocol: TCP
selector:
app: selenoid-ui
sessionAffinity: None
type: LoadBalancer
status:
loadBalancer: {}

Ingress (Zorunlu değil)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: selenoid
namespace: selenosis
annotations:
nginx.ingress.kubernetes.io/proxy-connect-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
rules:
- host: selenosis.test.in
http:
paths:
- backend:
service:
name: selenosis
port:
number: 4444
path: /
pathType: Prefix
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: selenoid-ui
namespace: selenosis
annotations:
nginx.ingress.kubernetes.io/proxy-connect-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
rules:
- host: selenosis-ui.test.in
http:
paths:
- backend:
service:
name: selenoid-ui
port:
number: 8080
path: /
pathType: Prefix

Örnek

1024 parallel browser ayaklandırmayı deneyeceğim.

Aşağıda Selenoid-ui ile anlık browserların create ve close görüntülenmektedir.

Burada da namespace’teki cpu ve memory metrikleri görüntülenmektedir.

RemoteWebDriver konfigürasyonları

Network hataları alırsanız aşağıdaki düzenlemeleri yapabilirsiniz.

RemoteWebDriver’ın CommandExecutor özelliğini kullanarak istekleri yönetebilirsiniz.

RemoteWebDriver webDriver = new RemoteWebDriver(new RemoteConnectionManager().getCommandExecutor(Launchpad.Url(webConfiguration)), capabilities);

RemoteWebDriver’ın yaptığı isteklerde connectionTimeout, readTimeout gibi ayarlar yapabilirsiniz. Ayrıca istekleri verdiğiniz koşullara göre retry da edebilirsiniz.

public class RemoteConnectionManager {

public TracedCommandExecutor getCommandExecutor(URL url) {
CommandExecutor executor;
OpenTelemetryTracer tracer;
try {
RetryPolicyHandler retryPolicy = new RetryPolicyHandler();

Filter filter = new AddSeleniumUserAgent().andThen(retryPolicy);

ClientConfig config = ClientConfig
.defaultConfig()
.baseUrl(url)
.connectionTimeout(Duration.ofMinutes(15))
.readTimeout(Duration.ofMinutes(15))
.withFilter(filter.andThen(new RetryRequest()));
tracer = OpenTelemetryTracer.getInstance();
HttpClient.Factory httpClientFactory = HttpClient.Factory.createDefault();
TracedHttpClient.Factory tracedHttpClientFactory = new TracedHttpClient.Factory(tracer,
httpClientFactory);

executor = new HttpCommandExecutor(Collections.emptyMap(), config,
tracedHttpClientFactory);
} catch (Exception e) {
throw new WebTestCoreException("Remote Commander initialize error");
}

return new TracedCommandExecutor(executor, tracer);
}
}

Retry koşulları ve kurallarını burada belirleyebilirsiniz.

public class RetryPolicyHandler implements Filter {

private static final AtomicReference<HttpResponse> fallBackResponse = new AtomicReference<>();
private static final Fallback<Object> fallback = Fallback.of(fallBackResponse::get);

private static final RetryPolicy<Object> connectionFailurePolicy =
RetryPolicy.builder()
.handleIf(failure -> failure.getCause() instanceof ConnectException)
.withMaxRetries(10)
.withMaxDuration(Duration.ofMinutes(1))
.onRetry(e -> log.info(String.format(
"Connection failure #%s. Retrying.",
e.getAttemptCount())))
.build();
private static final RetryPolicy<Object> readTimeoutPolicy =
RetryPolicy.builder()
.handle(TimeoutException.class)
.withMaxRetries(10)
.withMaxDuration(Duration.ofMinutes(1))
.onRetry(e -> log.info(String.format(
"Read timeout #%s. Retrying.",
e.getAttemptCount())))
.build();

private static final RetryPolicy<Object> serverRestartPolicy =
RetryPolicy.builder()
.handleResultIf(response -> ((HttpResponse) response).getStatus() == HTTP_BAD_GATEWAY)
.withMaxRetries(10)
.withMaxDuration(Duration.ofMinutes(1))
.onRetry(e -> log.warn(String.format(
"Failure due to server restart error #%s. Retrying.",
e.getAttemptCount())))
.build();

private static final RetryPolicy<Object> serverErrorPolicy =
RetryPolicy.builder()
.handleResultIf(
response -> ((HttpResponse) response).getStatus() == HTTP_INTERNAL_ERROR &&
Integer.parseInt(((HttpResponse) response).getHeader(CONTENT_LENGTH)) == 0)
.handleResultIf(response -> ((HttpResponse) response).getStatus() == HTTP_UNAVAILABLE)
.withMaxRetries(10)
.withMaxDuration(Duration.ofMinutes(1))
.onRetry(e -> log.warn(String.format(
"Failure due to server error #%s. Retrying.",
e.getAttemptCount())))
.build();

@Override
public HttpHandler apply(HttpHandler next) {
return req -> Failsafe
.with(fallback)
.compose(serverErrorPolicy)
.compose(serverRestartPolicy)
.compose(readTimeoutPolicy)
.compose(connectionFailurePolicy)
.get(() -> next.execute(req));
}
}

Özet

Bu makalede Selenosis uygulaması ile Selenoid’in kubernetes ortamında kurulumu ve 1024 paralel browser gibi yüksek sessionlar açabilmek için uyguladığım çözüm ve yaptığımda geliştirmeyi paylaştım. Yüksek sessionlara ihtiyaç duyulduğunda bu çözümü uygulayabilirsiniz.

Okuduğunuz için teşekkür ederim.

Referans

  1. https://github.com/alcounit/selenosis
  2. https://github.com/alcounit/selenosis-deploy
  3. https://github.com/AbdullahSrky/selenosis/tree/release/v1.0.6
  4. https://hub.docker.com/r/abdullahsrky/selenosis/tags

--

--