Skip to content

Commit a2c91d3

Browse files
authored
test(e2e): prewarm environment pool to hide per-spec deploy latency (#426)
1 parent a3d4540 commit a2c91d3

7 files changed

Lines changed: 544 additions & 7 deletions

File tree

test/e2e/apisix/e2e_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,8 @@ func TestAPISIXE2E(t *testing.T) {
4545
_, _ = fmt.Fprintf(GinkgoWriter, "Starting APISIX standalone e2e suite\n")
4646
RunSpecs(t, "apisix standalone e2e suite")
4747
}
48+
49+
// Tear down any prewarmed environments left in the pools when the suite ends.
50+
var _ = AfterSuite(func() {
51+
scaffold.ShutdownAllPools()
52+
})

test/e2e/crds/v2/route.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2196,9 +2196,19 @@ spec:
21962196
&apiv2.ApisixRoute{}, fmt.Sprintf(apisixRouteSpec, s.Namespace()))
21972197

21982198
By("check upstreams")
2199-
upstreams, err := s.DefaultDataplaneResource().Upstream().List(context.Background())
2200-
Expect(err).ShouldNot(HaveOccurred())
2201-
Expect(upstreams).Should(HaveLen(4))
2199+
// Poll instead of asserting once: the controller syncs the four
2200+
// upstreams to the data plane asynchronously after the ApisixRoute is
2201+
// applied, so a single immediate check can race and observe fewer.
2202+
s.RetryAssertion(func() error {
2203+
upstreams, err := s.DefaultDataplaneResource().Upstream().List(context.Background())
2204+
if err != nil {
2205+
return err
2206+
}
2207+
if len(upstreams) != 4 {
2208+
return fmt.Errorf("expected 4 upstreams, got %d", len(upstreams))
2209+
}
2210+
return nil
2211+
}).ShouldNot(HaveOccurred(), "waiting for 4 upstreams to be synced")
22022212

22032213
By("verify ApisixRoute works")
22042214
s.RequestAssert(&scaffold.RequestAssert{

test/e2e/framework/k8s.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ func (f *Framework) ensureService(name, namespace string, desiredEndpoints int)
7474
return f.ensureServiceWithTimeout(name, namespace, desiredEndpoints, 120)
7575
}
7676

77+
// EnsureServiceReadyE waits until the named Service has the desired number of
78+
// ready endpoints, returning an error instead of failing the test. It is used by
79+
// the prewarm pool, whose background workers must not call Ginkgo assertions.
80+
func (f *Framework) EnsureServiceReadyE(namespace, name string, desiredEndpoints int) error {
81+
return f.ensureService(name, namespace, desiredEndpoints)
82+
}
83+
7784
func (f *Framework) ensureServiceWithTimeout(name, namespace string, desiredEndpoints, timeout int) error {
7885
backoff := wait.Backoff{
7986
Duration: 6 * time.Second,
@@ -290,10 +297,14 @@ func WaitPodsAvailable(t testing.TestingT, kubeOps *k8s.KubectlOptions, opts met
290297
}
291298

292299
func waitExponentialBackoff(condFunc func() (bool, error)) error {
300+
// Poll at a fixed 2s interval up to ~180s. The previous exponential schedule
301+
// (500ms, factor 2, 8 steps) polled at 7.5/15.5/31.5/63.5s, i.e. sparsely
302+
// exactly during the 10-30s window when pods usually become ready, adding up
303+
// to ~15s of needless waiting per call.
293304
backoff := wait.Backoff{
294-
Duration: 500 * time.Millisecond,
295-
Factor: 2,
296-
Steps: 8,
305+
Duration: 2 * time.Second,
306+
Factor: 1,
307+
Steps: 90,
297308
}
298309
return wait.ExponentialBackoff(backoff, condFunc)
299310
}

test/e2e/scaffold/apisix_deployer.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,34 @@ func NewAPISIXDeployer(s *Scaffold) Deployer {
5959
}
6060

6161
func (s *APISIXDeployer) BeforeEach() {
62+
// Fast path: pick up a prewarmed environment so the deploy/readiness
63+
// latency is paid by a background worker (overlapping the previous spec)
64+
// instead of on this spec's critical path. Only the default profile is
65+
// pooled; anything else, or any provisioning failure, falls back to the
66+
// synchronous deploy below.
67+
if prewarmEnabled() && isPoolable(s.opts) && specPrewarmable() {
68+
fw, opts := s.Framework, s.opts
69+
pool := getOrStartPool(profileKey(opts), prewarmDepth(), func() *pooledEnv {
70+
return provisionAPISIXEnv(fw, opts)
71+
})
72+
env := pool.acquire()
73+
if env != nil && env.err == nil {
74+
if err := s.loadPooledEnv(env); err != nil {
75+
s.Logf("prewarm tunnel setup failed, falling back to synchronous deploy: %v", err)
76+
destroyPooledEnv(env)
77+
} else {
78+
return
79+
}
80+
} else if env != nil && env.err != nil {
81+
s.Logf("prewarm provision failed, falling back to synchronous deploy: %v", env.err)
82+
destroyPooledEnv(env)
83+
}
84+
}
85+
86+
s.beforeEachSync()
87+
}
88+
89+
func (s *APISIXDeployer) beforeEachSync() {
6290
s.runtimeOpts = s.opts
6391
s.namespace = fmt.Sprintf("ingress-apisix-e2e-tests-%s-%d", s.runtimeOpts.Name, time.Now().Nanosecond())
6492
s.kubectlOptions = &k8s.KubectlOptions{
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package scaffold
19+
20+
import (
21+
"bytes"
22+
"fmt"
23+
"os"
24+
"strings"
25+
"sync/atomic"
26+
"time"
27+
28+
"github.com/gruntwork-io/terratest/modules/k8s"
29+
. "github.com/onsi/ginkgo/v2" //nolint:staticcheck
30+
corev1 "k8s.io/api/core/v1"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
32+
"k8s.io/utils/ptr"
33+
34+
"github.com/apache/apisix-ingress-controller/test/e2e/framework"
35+
)
36+
37+
var nsCounter int64
38+
39+
// defaultProfileName is the scaffold Options.Name used by the default profile.
40+
const defaultProfileName = "default"
41+
42+
// streamRouteLabels marks Gateway API stream-route specs (TCP/TLS/UDP).
43+
var streamRouteLabels = map[string]struct{}{
44+
"tcproute": {},
45+
"tlsroute": {},
46+
"udproute": {},
47+
}
48+
49+
// specPrewarmable reports whether the currently running spec may be served from
50+
// the prewarm pool. Stream-route specs (Gateway API TCP/TLS/UDP routes and the
51+
// ApisixRoute stream-route suite) are excluded and fall back to synchronous
52+
// deployment: under apisix-standalone a stream route served from a prewarmed
53+
// data plane is flaky, because the data plane's stream subsystem races with the
54+
// concurrent background provisioning of the next pooled environment, whereas
55+
// HTTP routes tolerate that contention.
56+
func specPrewarmable() bool {
57+
report := CurrentSpecReport()
58+
for _, label := range report.Labels() {
59+
if _, ok := streamRouteLabels[label]; ok {
60+
return false
61+
}
62+
}
63+
for _, text := range report.ContainerHierarchyTexts {
64+
if strings.Contains(text, "StreamRoute") {
65+
return false
66+
}
67+
}
68+
return true
69+
}
70+
71+
// isPoolable reports whether an environment with these options can be served
72+
// from the prewarm pool. Only the default profile is pooled; webhook-enabled
73+
// and custom-keyed environments fall back to synchronous deployment.
74+
func isPoolable(o Options) bool {
75+
return !o.SkipHooks &&
76+
!o.EnableWebhook &&
77+
o.ControllerName == "" &&
78+
o.APISIXAdminAPIKey == ""
79+
}
80+
81+
// profileKey identifies the pool an environment belongs to. Within a process
82+
// all default scaffolds share one pool.
83+
func profileKey(o Options) string {
84+
name := o.Name
85+
if name == "" {
86+
name = defaultProfileName
87+
}
88+
return "name=" + name
89+
}
90+
91+
func formatRegistry(workloadTemplate string) string {
92+
if customRegistry, ok := os.LookupEnv("REGISTRY"); ok {
93+
return strings.ReplaceAll(workloadTemplate, "127.0.0.1:5000", customRegistry)
94+
}
95+
return workloadTemplate
96+
}
97+
98+
// provisionAPISIXEnv builds a complete default-profile environment using
99+
// error-returning primitives only, so it is safe to run in a background
100+
// goroutine. Any failure is captured in pooledEnv.err for the caller to handle.
101+
func provisionAPISIXEnv(fw *framework.Framework, opts Options) *pooledEnv {
102+
t := &bgTestingT{}
103+
env := &pooledEnv{}
104+
105+
name := opts.Name
106+
if name == "" {
107+
name = defaultProfileName
108+
}
109+
ns := fmt.Sprintf("ingress-apisix-e2e-tests-%s-p%d-%d",
110+
name, GinkgoParallelProcess(), atomic.AddInt64(&nsCounter, 1))
111+
env.namespace = ns
112+
env.kubectlOptions = &k8s.KubectlOptions{
113+
ConfigPath: GetKubeconfig(),
114+
Namespace: ns,
115+
}
116+
env.adminKey = getEnvOrDefault("APISIX_ADMIN_KEY", "edd1c9f034335f136f87ad84b625c8f1")
117+
env.controllerName = fmt.Sprintf("%s/%s", DefaultControllerName, ns)
118+
119+
if err := k8s.CreateNamespaceE(t, env.kubectlOptions, ns); err != nil {
120+
env.err = fmt.Errorf("creating namespace: %w", err)
121+
return env
122+
}
123+
124+
// 1) Data plane (APISIX, plus etcd when the provider needs it).
125+
svc, err := provisionDataplane(t, env, opts)
126+
if err != nil {
127+
env.err = err
128+
return env
129+
}
130+
env.dataplaneService = svc
131+
132+
// 2) Ingress controller.
133+
if err := provisionIngress(t, env); err != nil {
134+
env.err = err
135+
return env
136+
}
137+
138+
// 3) httpbin test backend.
139+
httpbinSvc, err := provisionHTTPBIN(fw, t, env)
140+
if err != nil {
141+
env.err = err
142+
return env
143+
}
144+
env.httpbinService = httpbinSvc
145+
146+
return env
147+
}
148+
149+
func provisionDataplane(t *bgTestingT, env *pooledEnv, _ Options) (*corev1.Service, error) {
150+
serviceName := framework.ProviderType
151+
configProvider := framework.ConfigProviderTypeYaml
152+
if framework.ProviderType == framework.ProviderTypeAPISIX {
153+
configProvider = framework.ConfigProviderTypeEtcd
154+
}
155+
156+
if configProvider == framework.ConfigProviderTypeEtcd {
157+
if err := k8s.KubectlApplyFromStringE(t, env.kubectlOptions, framework.EtcdSpec); err != nil {
158+
return nil, fmt.Errorf("applying etcd: %w", err)
159+
}
160+
if err := framework.WaitPodsAvailable(t, env.kubectlOptions, metav1.ListOptions{
161+
LabelSelector: "app=etcd",
162+
}); err != nil {
163+
return nil, fmt.Errorf("waiting for etcd pod: %w", err)
164+
}
165+
}
166+
167+
deployOpts := APISIXDeployOptions{
168+
Namespace: env.namespace,
169+
AdminKey: env.adminKey,
170+
ServiceName: serviceName,
171+
ServiceHTTPPort: 9080,
172+
ServiceHTTPSPort: 9443,
173+
ConfigProvider: configProvider,
174+
Replicas: ptr.To(1),
175+
}
176+
buf := bytes.NewBuffer(nil)
177+
if err := framework.APISIXStandaloneTpl.Execute(buf, &deployOpts); err != nil {
178+
return nil, fmt.Errorf("rendering apisix template: %w", err)
179+
}
180+
if err := k8s.KubectlApplyFromStringE(t, env.kubectlOptions, buf.String()); err != nil {
181+
return nil, fmt.Errorf("applying apisix: %w", err)
182+
}
183+
if err := framework.WaitPodsAvailable(t, env.kubectlOptions, metav1.ListOptions{
184+
LabelSelector: "app.kubernetes.io/name=apisix",
185+
}); err != nil {
186+
return nil, fmt.Errorf("waiting for apisix pod: %w", err)
187+
}
188+
189+
svc, err := k8s.GetServiceE(t, env.kubectlOptions, serviceName)
190+
if err != nil {
191+
return nil, fmt.Errorf("getting dataplane service: %w", err)
192+
}
193+
return svc, nil
194+
}
195+
196+
func provisionIngress(t *bgTestingT, env *pooledEnv) error {
197+
opts := framework.IngressDeployOpts{
198+
ControllerName: env.controllerName,
199+
ProviderType: framework.ProviderType,
200+
ProviderSyncPeriod: 1 * time.Hour,
201+
Namespace: env.namespace,
202+
Replicas: ptr.To(1),
203+
WebhookEnable: false,
204+
}
205+
buf := bytes.NewBuffer(nil)
206+
if err := framework.IngressSpecTpl.Execute(buf, opts); err != nil {
207+
return fmt.Errorf("rendering ingress template: %w", err)
208+
}
209+
if err := k8s.KubectlApplyFromStringE(t, env.kubectlOptions, buf.String()); err != nil {
210+
return fmt.Errorf("applying ingress controller: %w", err)
211+
}
212+
if err := framework.WaitPodsAvailable(t, env.kubectlOptions, metav1.ListOptions{
213+
LabelSelector: "control-plane=controller-manager",
214+
}); err != nil {
215+
return fmt.Errorf("waiting for controller pod: %w", err)
216+
}
217+
return nil
218+
}
219+
220+
func provisionHTTPBIN(fw *framework.Framework, t *bgTestingT, env *pooledEnv) (*corev1.Service, error) {
221+
deployment := fmt.Sprintf(formatRegistry(_httpbinDeploymentTemplate), 1)
222+
if err := k8s.KubectlApplyFromStringE(t, env.kubectlOptions, deployment); err != nil {
223+
return nil, fmt.Errorf("applying httpbin deployment: %w", err)
224+
}
225+
if err := k8s.KubectlApplyFromStringE(t, env.kubectlOptions, _httpService); err != nil {
226+
return nil, fmt.Errorf("applying httpbin service: %w", err)
227+
}
228+
if err := fw.EnsureServiceReadyE(env.namespace, HTTPBinServiceName, 1); err != nil {
229+
return nil, fmt.Errorf("waiting for httpbin endpoints: %w", err)
230+
}
231+
svc, err := k8s.GetServiceE(t, env.kubectlOptions, HTTPBinServiceName)
232+
if err != nil {
233+
return nil, fmt.Errorf("getting httpbin service: %w", err)
234+
}
235+
return svc, nil
236+
}
237+
238+
// loadPooledEnv installs a prewarmed environment onto the deployer's scaffold so
239+
// the rest of the spec behaves exactly as if it had been deployed synchronously.
240+
//
241+
// Port-forward tunnels are (re)created here, on the spec's critical path, rather
242+
// than reused from the background prewarm worker. A port-forward opened during
243+
// prewarm can be left in a broken state when it is established before the data
244+
// plane's listener is fully serving (e.g. the TLS stream listener) and then sits
245+
// idle in the pool buffer until a spec picks it up, surfacing as an EOF on first
246+
// use. Recreating them here against a fully-provisioned data plane matches the
247+
// synchronous path exactly; tunnel setup is cheap (~1-2s) relative to the
248+
// deploy/readiness latency that prewarm hides.
249+
func (s *APISIXDeployer) loadPooledEnv(env *pooledEnv) error {
250+
s.runtimeOpts = s.opts
251+
s.namespace = env.namespace
252+
s.kubectlOptions = env.kubectlOptions
253+
s.runtimeOpts.ControllerName = env.controllerName
254+
s.runtimeOpts.APISIXAdminAPIKey = env.adminKey
255+
s.dataplaneService = env.dataplaneService
256+
s.httpbinService = env.httpbinService
257+
s.finalizers = nil
258+
s.additionalGateways = make(map[string]*GatewayResources)
259+
260+
apisixTunnels, err := s.createDataplaneTunnels(env.dataplaneService, s.kubectlOptions, env.dataplaneService.Name)
261+
if err != nil {
262+
return fmt.Errorf("creating dataplane tunnels: %w", err)
263+
}
264+
s.apisixTunnels = apisixTunnels
265+
266+
adminTunnel, err := s.createAdminTunnel(env.dataplaneService)
267+
if err != nil {
268+
return fmt.Errorf("creating admin tunnel: %w", err)
269+
}
270+
s.adminTunnel = adminTunnel
271+
272+
return nil
273+
}

0 commit comments

Comments
 (0)