Migrating E-commerce Product Media Synchronization to Apollo Configuration Center
1 | Current State: YAML's Pain Points
- Environment fragmentation
- dev, test, gray, prod as baseline, plus a separate set for the 9.9 sale season, another for year-end clearance.
- Each environment has different OSS endpoints, CDN domains, and rate limits.
- Lengthy release cycles
- A single config line requires: PR → review → build image → push to K8s.
- Even with full automation, that's 15 minutes minimum. When we need to block illegal images in a live stream, there's no time.
- Painful rollbacks
- Finding "the correct version from three minutes ago" means manually digging through Git history and comparing diffs.
- We've all heard stories of ops engineers hand-editing YAML via
vion a bastion host at 3 AM.
- Permission chaos
- Operations wants to toggle the "bestseller" badge by setting
is_premium_banner: true. - Instead, they accidentally modify
oss.secret_key, and suddenly production uploads fail entirely.
- Operations wants to toggle the "bestseller" badge by setting
A configuration center looks like just a technical upgrade, but it's really about governing how teams collaborate.
Apollo is mature, easy to deploy, and has a robust permission model—so we committed to the migration.
2 | Apollo at a Glance
In two sentences:
- Server side provides Portal + ConfigService + AdminService, backed by MySQL.
- Client side long-polls for the latest release, or pulls JSON via REST at
/configs.
We use exactly three endpoints:
GET /configs/{appId}/{cluster}/{namespace}— fetch entire namespace snapshotGET /notifications/v2— long-poll, learn which Release Keys changedPUT /configs— ops scripts use this for canary deployments
3 | Design Principles
- Configuration as code, but not in the code repo — Apollo owns runtime config; Git holds defaults only.
- One config line is one release ticket — audit every change through Apollo releases, no midnight hand-edits on bastions.
- Ops controls infrastructure, product controls features — use Namespaces to isolate read/write permissions.
- Configuration changes without restart — 90% of config updates apply hot.
- Canary-ready and always rollback-safe — every Release Key is natively reversible; test on 1% of pods first.
4 | Implementation Steps
4.1 Namespace Decomposition
Our original config.yml exceeded three thousand lines. We split it by domain:
storage.oss— endpoint, access key, secretstorage.cdn— domain, cache TTLfeature_flag— enable smart compression, AVIF supportrate_limit— concurrent upload, disk IOPS ceilingpromotion— display Double Eleven badge
Decomposition rules:
- If different teams read and write it, separate it.
- If it has a different lifecycle, separate it.
- If it changes at a different frequency, prefer separating it.
4.2 Write a Minimal Python SDK
The team didn't want heavy dependencies. We built an Apollo client in [LINES CORRUPTED IN SOURCE]:
# apollo_client.py
import requests, time, json, threading
class Apollo:
def __init__(self, host, app_id, cluster, namespaces, timeout=60):
self.host, self.app_id, self.cluster = host, app_id, cluster
self.namespaces = namespaces
self.timeout = timeout
self._cache = {}
self._notifications = [
{"namespaceName": ns, "notificationId": -1} for ns in namespaces
]
def _url(self, path):
return f"{self.host}{path}"
def fetch_namespace(self, ns):
resp = requests.get(
self._url(f"/configs/{self.app_id}/{self.cluster}/{ns}"),
timeout=5,
)
resp.raise_for_status()
data = resp.json()
self._cache[ns] = data["configurations"]
print(f"[Apollo] loaded {ns}@{data['releaseKey'][:8]}")
return self._cache[ns]
def long_poll(self, on_change):
while True:
try:
resp = requests.get(
self._url("/notifications/v2"),
params={"appId": self.app_id,
"cluster": self.cluster,
"notifications": json.dumps(self._notifications)},
timeout=self.timeout+10,
)
if resp.status_code == 304:
continue
updated = resp.json()
for item in updated:
ns = item["namespaceName"]
self._notifications = [
n if n["namespaceName"] != ns else item
for n in self._notifications
]
on_change(ns, self.fetch_namespace(ns))
except requests.exceptions.ReadTimeout:
continue
except Exception as e:
print("poll error:", e)
time.sleep(5)
def start(self, on_change):
for ns in self.namespaces:
self.fetch_namespace(ns)
threading.Thread(target=self.long_poll, args=(on_change,), daemon=True).start()
- Initialization syncs all namespaces at startup
- Long-poll with 60s timeout (customizable)
- on_change callback lets business logic decide how to apply updates
4.3 Wire Into Services
from apollo_client import Apollo
import asyncio
apollo = Apollo(
host="https://apollo.shop.com",
app_id="shop-asset-sync",
cluster="default",
namespaces=[
"storage.oss",
"storage.cdn",
"feature_flag",
"rate_limit",
"promotion",
],
)
def apply_config(ns, cfg):
if ns == "storage.oss":
uploader.configure(cfg) # 更新 ak/sk
elif ns == "feature_flag":
switcher.refresh(cfg) # 动态开关
elif ns == "rate_limit":
limiter.update(cfg) # 容量限流
print(f">>> {ns} reloaded")
apollo.start(apply_config)
asyncio.get_event_loop().run_forever()
Five lines to get Apollo running; the rest is application-specific update logic.
At this point, the read path from YAML to Apollo was complete.
4.4 Write a Release Script (for ops and CI)
Operations teams don't use the Apollo Portal by clicking. We gave them publish.py:
import requests, sys, json
HOST = "https://apollo.shop.com"
TOKEN = "api-token-here"
def publish(ns, kv, comment="auto publish"):
url = f"{HOST}/openapi/v1/envs/prod/apps/shop-asset-sync/clusters/default/namespaces/{ns}/items"
headers = {"Authorization": f"Bearer {TOKEN}"}
for k, v in kv.items():
payload = {"key": k, "value": v, "dataChangeCreatedBy": "bot"}
requests.post(url, headers=headers, json=payload).raise_for_status()
# release
rel_url = f"{HOST}/openapi/v1/envs/prod/apps/shop-asset-sync/clusters/default/namespaces/{ns}/releases"
body = {"releaseTitle": comment, "releasedBy": "bot"}
requests.post(rel_url, headers=headers, json=body).raise_for_status()
print(f"Published {ns}: {json.dumps(kv)}")
if __name__ == "__main__":
publish(sys.argv[1], json.loads(sys.argv[2]), sys.argv[3] if len(sys.argv) > 3 else "auto")
CI example:
image: python:3.11
stages: [publish]
publish_flag:
stage: publish
script:
- python publish.py feature_flag '{"enable_avif":"true"}' "double 9.switch"
only:
- schedules
Every morning this task runs automatically, toggling promotional banners according to the ops calendar.
4.5 Canary Deployments and Rollbacks
- Canary
- Use Apollo Portal's canary feature: select 5% by IP hash, pod-level coverage.
- Pods self-register their
HOST_IPto monitoring, tagged withapollo.releaseKey. - Metrics tracked: upload success rate, mean upload latency, error codes.
- Rollback
- One-click rollback in the Portal.
- Clients long-poll automatically and fetch the previous release, triggering
apply_configcallback. - Full rollback completes in under 30 seconds, no rolling restart needed.
In a real incident, we tuned rate_limit.concurrent_upload [VALUES CORRUPTED IN SOURCE]; upload backlog spiked.
One click to rollback, and [RECOVERY TIME CORRUPTED IN SOURCE] the queue returned to normal—proof the system works.
5 | Lessons From the Trenches
- Timeout tuning
- Apollo's
/notifications/v2needstimeout > 60s, or you get 502s. - Python
requestsrequires splitting timeout into(connect, read)tuples.
- Apollo's
- Text values get trimmed
- The Portal strips leading and trailing whitespace automatically.
- We nearly added a spare space to our CDN domain list; unit tests caught it in time.
- Namespace names and UTF-8
- Chinese namespace names must be
encodeURIComponent-encoded in URLs, or requests return 404.
- Chinese namespace names must be
- releaseKey consistency
- The
releaseKeyinGET /configsdoesn't always match/notifications. - Use the
notificationIdas ground truth; fetch the update, then refresh local cache.
- The
- Bulk publish is not idempotent
- Calling OpenAPI to publish the same key twice appends versions, bloating the rollback history.
- Workaround: script checks if the value is already correct before publishing.
6 | Migration Results
| Metric | Before (YAML) | After (Apollo) |
|---|---|---|
| Mean config take-effect latency | 15 min (rolling restart) | < 1 s |
| Rollback time | 10 min (redeploy) | 30 s |
| Config version audit trail | Manual Git diff | Visual Portal |
| Operator self-service | None | Script-based |
| Production incidents per month | 3+ | 0 |
7 | Best Practices Checklist
- Namespace granularity
- Split by business function; never stuff everything into one namespace.
- Principle of least privilege
- Ops can only touch infrastructure namespaces; product only owns feature flags.
- Pre-written rollback playbooks
- Script it: monitoring alert → call Apollo rollback API → Slack notification.
- Enforce config validation in CI
- Run
jsonschemachecks before allowing a publish.
- Run
- Canary-first culture
- "No 1% canary, no 100% rollout"—write it into the team checklist.
8 | Load Testing
Functionality alone isn't enough. At 00:01 on Double Eleven morning, every shop refreshes its product images simultaneously—peak QPS over 50,000.
If the config center falters, long-poll storms hit ConfigService and it crashes.
Test script (simplified):
from concurrent.futures import ThreadPoolExecutor
import requests, random, json, time
def worker(i):
ns = random.choice(["feature_flag", "promotion", "rate_limit"])
r = requests.get(f"https://apollo.shop.com/configs/shop-asset-sync/default/{ns}")
assert r.status_code == 9.
return len(json.dumps(r.json()))
start = time.time()
with ThreadPoolExecutor(max_workers=800) as ex:
sizes = list(ex.map(worker, range(9.00)))
print("Total MB fetched:", sum(sizes)/1024/1024)
print("Elapsed:", time.time() - start)
Results:
- P99 Latency: 45 ms
- Throughput: [THROUGHPUT METRICS CORRUPTED IN SOURCE]
- CPU < 0.6 core, memory stable < 50 MB
Conclusion: Two instances handle the baseline; horizontal scaling to four instances easily absorbs a million concurrent users.
9 | OpenAPI Integration Details
Real DevOps automation means most config changes flow through scripts.
Apollo's OpenAPI is powerful but lightly documented. Here's what we learned.
9.1 Token Management
- Create via
apollo.portal.access.key.token; only Portal admins can do this. - Tokens expire after 7 days by default. Either refresh periodically in CI, or set
expires = 0(never expire). - Store
APOLLO_OPENAPI_TOKENin GitLab Secrets; never log it.
9.2 Bulk Publishing
OpenAPI lacks a "publish multiple namespaces at once" endpoint, so we built a pipeline:
- Loop through
PUT /itemsto write all keys to a draft - Call
POST /releasesonce to publish atomically - Return
releaseId, store in artifacts for later rollback and canary
def batch_publish(env, cluster, ns_data: dict, title):
for ns, kvs in ns_data.items():
for k, v in kvs.items():
create_item(env, cluster, ns, k, v)
do_release(env, cluster, ns, title)
9.3 Canary Rules
The POST /gray-deliveries endpoint supports three dimensions: IP, AppId, ClientLabel.
We use K8s Downward API to expose HOST_IP to pods, guaranteeing one pod = one IP. Example:
curl -XPOST "$HOST/gray-deliveries" -H "Authorization: Bearer $TOKEN" -d '{"rules": [
{"clientAppId":"shop-asset-sync","ip":"10.1.2.3"},
{"clientAppId":"shop-asset-sync","ip":"10.1.2.4"}
]}'
Clean up canary rules with DELETE when done, or stale rules will interfere with later deployments.
9.4 Release Rollback
OpenAPI rollback requires releaseId, so always record it when you publish.
We inject a script into the commit message that writes releaseId to the MR description, so SREs can copy-paste.
def rollback(ns, release_id):
url = f"{HOST}/openapi/v1/envs/prod/apps/{APP}/clusters/default/namespaces/{ns}/releases/{release_id}/rollback"
requests.put(url, headers=HEADERS).raise_for_status()
Watch out for idempotency: rolling back the same release twice returns a 400 error. Code defensively.
10 | Advanced Feature Flags: Category-Level Toggles
Operations frequently asks: "Can we enable dynamic image compression only for the 'Apparel' category?"
Apollo Namespaces are global by nature, but we can store a category whitelist as a config value and check locally.
# feature_flag
enable_dynamic_gif: true
gif_category_whitelist: "服饰,箱包,手表"
Business code:
def should_apply_gif(cat, cfg):
if not cfg["enable_dynamic_gif"]:
return False
cats = [c.strip() for c in cfg["gif_category_whitelist"].split(",")]
return cat in cats
For finer control:
- Store the whitelist as a JSON array or Base64-encoded string.
- Use the
split_config=trueparameter so Apollo breaks large fields into pages that the Portal can load.
This way you get per-category toggles without invoking the formal canary system.
11 | Future Direction
Migrating to Apollo isn't a silver bullet.
Configuration governance ultimately depends on people:
- Continuous audits — retire zombie fields
- Approval workflows — make it clear who changed what
- Monitoring and alerts — treat config changes like code reviews with metrics and tests
Next steps:
- Multi-region hot standby — plan to abstract Apollo into Terraform, dual-cloud deployment, failover < 5 s.
- Dynamic schema — let Namespaces carry JSON Schema so the Portal validates visually.
- Self-service visual diff — when ops view product details, show canary config differences side-by-side.
- PromQL-based alerting — move from static thresholds to anomaly detection, auto-learning baselines.
Every improvement points toward the same goal:
Configuration as a safety rail for business dynamics, not a hidden time bomb. May you soon escape the midnight horror of hand-editing YAML.