সরাসরি পর্বে যান
পর্ব ৫৭ · ৮১-এর মধ্যে · মডিউল ১০

CI/CD pipeline-এর নকশা — stage, cache, parallel আর গতি

CI/CD Pipeline Design — Stages, Caching, Parallelism and Speed

Taught in Bangla, this lesson designs pipelines as DAGs and finds the critical path, compares hosted and self-hosted runners, orders checks to fail fast, measures dependency and Docker layer caching, explains cache poisoning, matrix builds, test sharding and Amdahl's limit, flaky tests, monorepo path filters, GitHub Actions and GitLab CI forms, and pipeline health metrics, with a simulator.

এই পর্বে যা শিখবেন

  • Stage, job আর step-এর পার্থক্য; needs দিয়ে DAG আঁকা আর critical path বের করা
  • Hosted বনাম self-hosted runner, আর self-hosted runner-কে নিরাপদ রাখা
  • Dependency cache বনাম Docker layer cache, cache key, cache poisoning ঠেকানো, আর আসল মাপা build
  • Matrix, test sharding-এর হিসাব, Amdahl-এর সীমা, flaky test, monorepo-র path filter আর পুরোনো run বাতিল
  • GitHub Actions আর GitLab CI-তে একই নকশা, reusable workflow, আর pipeline-এর স্বাস্থ্য ও খরচ মাপা

১ · Pipeline একটি কারখানার লাইন: stage, job আর step

Continuous integration মানে সবার কাজ ঘন ঘন মূল branch-এ মেলানো আর প্রতিবার স্বয়ংক্রিয় যাচাই। Continuous delivery মানে প্রতিটি সবুজ build যেকোনো সময় release করার মতো রাখা। এই সংজ্ঞা আর তাদের ইতিহাস Software Engineering কোর্সের Continuous integration আর Continuous delivery ও DevOps পর্বে আছে। এই পর্ব তার পরের ধাপ: একটি pipeline-কে প্রকৌশলের চোখে নকশা করা। সে কত দ্রুত, কত সস্তা, কত নির্ভরযোগ্য, আর কতটা নিরাপদ?

একটি বিয়েবাড়ির ক্যাটারিং ভাবুন। “রান্না” একটি stage. তার ভেতরে “বিরিয়ানি” আর “রোস্ট” দুটি আলাদা job, দুই বাবুর্চি দুই চুলায় একসঙ্গে করেন। আর “চাল ধোয়া, মশলা দেওয়া, দম দেওয়া” হলো একটি job-এর step, যা পরপর হয়। বাবুর্চি আর চুলা হলো runner. মোট রান্নার ঘণ্টা বিল ঠিক করে, কিন্তু খাবার কখন আসবে তা ঠিক করে সবচেয়ে লম্বা পরপর-কাজের সারি।

স্তরকীএকসঙ্গে চলে?পরিবেশ
StageJob-এর দল, মানুষের চোখে পড়ার মতো ধাপএকটির পরে আরেকটিনিজের কিছু নেই
JobRunner-এ চলা কাজের এককহ্যাঁ, নির্ভরতা না থাকলেআলাদা, সাধারণত নতুন
StepJob-এর ভেতরের একটি command বা actionনা, পরপরএকই job-এর পরিবেশ

Job আলাদা যন্ত্রে চলে, তাই build-এর তৈরি file পরের job নিজে থেকে পায় না। সেটি artifact হিসেবে পাঠাতে হয়। Artifact-এর পুরো গল্প পরের পর্ব ৫৮-তে (Artifact আর promotion). GitLab-এর ভাবনার কেন্দ্রে stage, আর GitHub Actions-এ stage বলে আলাদা কিছু নেই, সেখানে শুধু job আর তাদের নির্ভরতা। শব্দ আলাদা, নকশার নীতি একই।

২ · DAG, needs আর critical path

“Build-এর পরে test” বলার সবচেয়ে সরল উপায় stage. কিন্তু stage-এ একটি লুকানো খরচ আছে: stage-এর সব job শেষ না হলে পরের stage শুরু হয় না, এমনকি পরের job যার ওপর নির্ভর করে না তার জন্যও। প্রতিটি job যদি নিজে বলে সে কার জন্য অপেক্ষা করবে, তাহলে pipeline একটি DAG হয়ে যায়, মানে দিক আছে কিন্তু বৃত্ত নেই এমন গ্রাফ। GitHub Actions আর GitLab CI দুটিতেই এর জন্য শব্দটি needs.

নিচে একটি কাল্পনিক অনলাইন বাজার অ্যাপ bazar-app-এর pipeline. প্রতিটি সংখ্যা ধরে নেওয়া সময়, মিনিটে। সব job-এর যোগফল ৪৫ মিনিট। লাল পথটি লক্ষ করুন।

একটি pipeline-এর DAG আর তার critical path

Total work 45 min · critical path 26 min · minutes are assumed

Pipeline DAG: critical path typecheck, build, image, e2e, publish commit lint · 2 typecheck · 3 unit · 6 build · 5 image · 4 scan · 3 e2e · 12 integ · 8 publish · 2 3 + 5 + 4 + 12 + 2 = 26
ছবি ৫৭.১ — তীর মানে “এর জন্য অপেক্ষা”। লাল পথের যোগফল ২৬ মিনিট, এটিই সবচেয়ে লম্বা নির্ভরতার শিকল। unit ৬ মিনিটের হলেও পথের বাইরে, তাই তার ১৮ মিনিটের ঢিল আছে: publish শুরু হয় মিনিট ২৪-এ, তাই unit ১৮ মিনিট দেরিতে শুরু হলেও মোট সময় বদলাত না।

Critical path হলো শুরু থেকে শেষ পর্যন্ত সবচেয়ে লম্বা নির্ভরতার পথ। অসীম runner থাকলেও pipeline এর চেয়ে দ্রুত শেষ হতে পারে না। দুটি নিচের সীমা মনে রাখুন, যেখানে R মানে runner-এর সংখ্যা:

সময় ≥ critical path
সময় ≥ মোট কাজ ÷ R

এখানে R = ২ হলে দ্বিতীয় সীমা ৪৫ ÷ ২ = ২২.৫ মিনিট, প্রথমটি ২৬ মিনিট, তাই ২৬-এর নিচে যাওয়া অসম্ভব। নিচের simulator একটি সত্যিকারের list scheduler চালায়: যে job-এর সব নির্ভরতা শেষ, তাদের মধ্যে লম্বাটি আগে খালি runner পায়। এখানে JOBS-এর সময় বদলে দেখুন, আর শেষ অংশের “যদি” পরীক্ষাগুলো নিজে যোগ করুন।

// Pipeline DAG with limited runners. Durations (minutes) are ASSUMED.
const JOBS = { lint: [2], typecheck: [3], unit: [6],
  build: [5, "lint", "typecheck"], image: [4, "build"], scan: [3, "image"],
  integ: [8, "image"], e2e: [12, "image"], publish: [2, "unit", "scan", "integ", "e2e"] };
const dur = (J, n) => J[n][0], needs = (J, n) => J[n].slice(1);
function criticalPath(J) {               // longest path through the DAG
  const memo = {};
  const best = n => memo[n] ??= (() => {
    const p = needs(J, n).map(best).sort((a, b) => b.len - a.len)[0];
    return { len: (p ? p.len : 0) + dur(J, n), path: [...(p ? p.path : []), n] };
  })();
  return Object.keys(J).map(best).sort((a, b) => b.len - a.len)[0];
}
function schedule(J, runners) {          // list scheduling: longest ready job first
  const end = {}, left = new Set(Object.keys(J)); let t = 0, busy = [];
  while (left.size || busy.length) {
    const ready = [...left].filter(n => needs(J, n).every(d => end[d] <= t))
      .sort((a, b) => dur(J, b) - dur(J, a));
    for (const n of ready.slice(0, runners - busy.length)) {
      left.delete(n); busy.push(n); end[n] = t + dur(J, n);
    }
    t = Math.min(...busy.map(n => end[n]));
    busy = busy.filter(n => end[n] > t);
  }
  return t;
}
const cp = criticalPath(JOBS);
const work = Object.keys(JOBS).reduce((a, n) => a + dur(JOBS, n), 0);
console.log(`total work ${work} min, critical path ${cp.len} min`);
console.log(cp.path.join(" -> "));
for (const r of [1, 2, 3, 4]) console.log(`runners=${r}  wall=${schedule(JOBS, r)} min`);
for (const [job, m] of [["unit", 3], ["e2e", 6], ["build", 3]]) {
  const what = { ...JOBS, [job]: [m, ...needs(JOBS, job)] };
  console.log(`if ${job}=${m} min: critical path ${criticalPath(what).len} min`);
}

আউটপুট:

total work 45 min, critical path 26 min
typecheck -> build -> image -> e2e -> publish
runners=1  wall=45 min
runners=2  wall=28 min
runners=3  wall=26 min
runners=4  wall=26 min
if unit=3 min: critical path 26 min
if e2e=6 min: critical path 22 min
if build=3 min: critical path 24 min

Runner ১ থেকে ২ করলে ১৭ মিনিট বাঁচল, ২ থেকে ৩ করলে ২ মিনিট, ৩ থেকে ৪ করলে কিছুই নয়। চতুর্থ runner টাকা খাবে, সময় দেবে না। এবার শেষ তিন লাইন। unit-কে অর্ধেক করলে শূন্য লাভ, কারণ সে critical path-এ নেই। e2e ১২ থেকে ৬ করলে ৬ মিনিট কমার কথা, কিন্তু কমল ৪, কারণ তখন integ-এর পথটি (৩ + ৫ + ৪ + ৮ + ২ = ২২) নতুন critical path হয়ে গেছে। একটি পথ ছোট করলে পরের লম্বা পথটি সামনে আসে।

মূল ধারণা

Pipeline দ্রুত করার প্রথম প্রশ্ন “কোন job সবচেয়ে ধীর?” নয়। প্রথম প্রশ্ন “critical path-এ কোন job?” আর দ্বিতীয় প্রশ্ন “এখন সময় যাচ্ছে কাজে, নাকি runner-এর লাইনে?”

৩ · Runner: hosted বনাম self-hosted

Runner হলো সেই যন্ত্র, যেখানে job চলে। Hosted runner platform-এর দেওয়া: প্রতিটি job-এ নতুন virtual machine, কাজ শেষে মুছে যায়। Self-hosted runner আপনার নিজের VM, server বা Kubernetes cluster-এ চলে। পার্থক্যটি CNG ভাড়া আর নিজের গাড়ির মতো: ভাড়ায় যত্নের ঝামেলা নেই, প্রতিবার গুনে টাকা; নিজের গাড়িতে পথ আর গ্যারেজ আপনার পছন্দের, কিন্তু চুরি আর মেরামতের দায়ও আপনার।

বিষয়HostedSelf-hosted
শুরুপ্রায় শূন্য ঝামেলাচালু, patch আর scale করা আপনার কাজ
খরচমিনিট ধরে, বিনামূল্যের সীমা platform-ভেদে আলাদাযন্ত্র আর মানুষের সময়, ব্যবহার না হলেও
Cacheপ্রতি job-এ খালি, network থেকে ফেরাতে হয়Disk-এ থাকতে পারে, তাই দ্রুত
Private networkসাধারণত আলাদা ব্যবস্থা লাগেআপনার VPC-র ভেতরে থাকতে পারে
Hardwareনির্দিষ্ট আকারের তালিকাGPU, ARM, বড় memory, যা খুশি
পরিচ্ছন্নতাপ্রতি job-এ নতুন যন্ত্রআগের job-এর চিহ্ন থেকে যেতে পারে

Self-hosted runner-এর নিরাপত্তা

শেষ সারিটিই সবচেয়ে বিপজ্জনক। একটি CI job-এর কাজই repository-র কোড চালানো। Pull request-এর কোডও কোড। একটি স্থায়ী runner-এ এক job যদি ক্ষতিকর কিছু রেখে যায়, পরের job, হয়তো release-এর job, সেই একই যন্ত্রে চলবে। আর runner যদি আপনার VPC-র ভেতরে থাকে, ঝুঁকি শুধু build-এ থেমে থাকে না। তাই নিয়মগুলো:

  • Ephemeral runner: একটি job, তারপর যন্ত্র মুছে ফেলা। Docker socket বা host-এর folder job-কে না দেওয়া।
  • Public repository-তে self-hosted নয়: GitHub-এর documentation লেখার সময়ে ঠিক এই সতর্কতাই দেয়, কারণ যে কেউ fork থেকে pull request পাঠাতে পারেন।
  • বিশ্বাসের স্তর আলাদা: Pull request-এর runner আর deploy-এর runner আলাদা group-এ। GitLab-এ “protected runner” শুধু protected branch-এর job নেয়।
  • Network আর পরিচয় সীমিত: runner-এর subnet থেকে শুধু দরকারি জায়গায় egress, আর instance-এর IAM role-এ শুধু ন্যূনতম অনুমতি (least privilege). Metadata service-এর পথ বন্ধ বা সীমিত।

Ephemeral runner-এ শুরুতে সময় আর bandwidth বেশি লাগে, কারণ disk-এর cache থাকে না। এই লেনদেন ইচ্ছা করেই নেওয়া হয়: নিরাপদ পরিচ্ছন্নতার বদলে একটু ধীর শুরু। পরের অংশের cache সেই ফাঁক ভরে।

৪ · Fail fast: দ্রুত আর সস্তা check আগে

লঞ্চঘাটে টিকিট দেখা হয় ঘাটেই, মাঝনদীতে নয়। Pipeline-এও একই নিয়ম: যে check সবচেয়ে দ্রুত আর সবচেয়ে বেশি ভুল ধরে, সে সবার আগে। একটি typo ধরা পড়ার জন্য ১২ মিনিটের end-to-end test-এর অপেক্ষা করা চলে না। ক্রম ঠিক করার হিসাবটি সহজ: প্রতিটি check-এর “ভুল ধরার সম্ভাবনা ÷ সময়” বের করুন, বেশি যার সে আগে। সাধারণত ক্রমটি হয় lint ও format, type check, unit test, build, integration test, শেষে e2e.

নিচের stepper-এ একটি commit ওপরের DAG ধরে এগোয়। Runner তিনটি, আর শুরুতে ১ মিনিট লাইনে অপেক্ষা ধরা হয়েছে।

Push-এর পরে কত মিনিট, আর কী চলছে
সময়
চলছে
সদ্য শেষ
খরচ হওয়া runner-মিনিট
  1. Push. Developer commit পাঠালেন। Runner খালি হওয়ার অপেক্ষায় ১ মিনিট কাটল। এই সময়টি queue time: কোনো কাজ হচ্ছে না, তবু মানুষ অপেক্ষা করছেন।
  2. মিনিট ১: তিনটি job. lint, typecheck আর unit কারও ওপর নির্ভর করে না, তাই একসঙ্গে শুরু। তিনটি runner-ই ব্যস্ত।
  3. মিনিট ৪: build শুরু। lint মিনিট ৩-এ, typecheck মিনিট ৪-এ শেষ। ভুল টাইপ থাকলে developer এখানেই, ৪ মিনিটে জেনে যেতেন। খরচ ২ + ৩ + ৩ = ৮ runner-মিনিট।
  4. মিনিট ৭: unit সবুজ। ৬ মিনিটের unit শেষ। Business logic-এর ভুল ধরার সংকেত এলো। build তখনো চলছে, ৩ মিনিট হয়ে গেছে, তাই মোট ২ + ৩ + ৬ + ৩ = ১৪।
  5. মিনিট ৯: build শেষ, image শুরু। মোট ২ + ৩ + ৬ + ৫ = ১৬ runner-মিনিট। image এখন একা চলছে, দুটি runner অলস।
  6. মিনিট ১৩: তিন পথে ভাগ। Image তৈরি, মোট ২০। এখন scan, integ আর e2e সেই একই image নিয়ে একসঙ্গে চলে। scan মিনিট ১৬-তে শেষ হয়।
  7. মিনিট ২১: integ সবুজ। ২০ + ৩ + ৮ + ৮ = ৩৯ runner-মিনিট। এক e2e-র জন্য পুরো pipeline অপেক্ষা করছে। এটিই critical path-এর চেহারা।
  8. মিনিট ২৫: e2e সবুজ। সব শর্ত পূরণ, publish শুরু। এখানে e2e লাল হলে publish চলতই না, আর ভুলের খবর আসত ২৫ মিনিট পরে। দামি ভুলগুলো তাই পরের দিকে ধরা পড়ে।
  9. মিনিট ২৭: শেষ। ১ মিনিট লাইন + ২৬ মিনিট critical path = ২৭ মিনিট। Runner-এর হিসাবে ৪৫ মিনিট বিল। মানুষের সময় ২৭, টাকার সময় ৪৫: এই দুটি সংখ্যা আলাদা, আর দুটিই মাপতে হয়।

GitHub Actions-এর matrix-এ fail-fast নামে একটি আলাদা সেটিং আছে, যা ডিফল্টে চালু থাকে: এক matrix job ব্যর্থ হলে বাকিগুলো বাতিল। Test shard-এ অনেক দল এটি বন্ধ রাখেন, যাতে একবারেই সব ব্যর্থ test দেখা যায়। “দ্রুত থামা” আর “সব খবর পাওয়া”, এই দুটির মধ্যে বেছে নিতে হয়।

৫ · Cache: dependency, Docker layer আর poisoning

Hosted runner প্রতিবার খালি যন্ত্র, তাই সব dependency প্রতিবার নামাতে হয়। Cache মানে আগের কাজের ফল জমিয়ে রাখা। Cache আর artifact-এর পার্থক্য Continuous integration পর্বে আছে। মূল নিয়মটি একটি বাক্যে: cache মুছে গেলেও pipeline-এর ফল একই হতে হবে, শুধু সময় বেশি লাগবে। Pipeline-এ cache থাকে মূলত দুই রূপে:

বিষয়Dependency cacheDocker layer cache
কী জমেpip বা npm-এর নামানো fileImage-এর layer, মানে প্রতিটি ধাপের ফল
চেনার উপায়আপনার লেখা cache key, সাধারণত lockfile-এর hashDockerfile-এর লাইন আর input file-এর checksum (পর্ব ৩৭)
ভাঙে কখনKey বদলালেএকটি ধাপ বদলালে সেটি আর তার পরের সব ধাপ
Hosted runner-এPlatform-এর cache store-এ রাখা হয়নিজে থেকে থাকে না, --cache-from দিয়ে আনতে হয়

Cache key কীভাবে বদলায়

নিচের stepper-এর hash আসল: একটি ১২ লাইনের requirements.txt-এর SHA-256 আর তাতে একটি লাইন যোগ করার পরের SHA-256, প্রথম ১২ অক্ষর। Package-এর সংখ্যাগুলোও আসল pip run থেকে নেওয়া।

Run আর cache store
পরিবর্তন
খোঁজা key
ফল
Package: cache থেকে / নতুন নামানো
Store-এ key
  1. প্রথম run. Store খালি, তাই miss. ১২টি package-ই নামানো হলো। Job-এর শেষে key linux-pip-aba17d73fe34-এর নামে cache জমা।
  2. শুধু app-এর কোড বদল। requirements.txt-এর একটি byte-ও বদলায়নি, তাই hash একই, key একই। Exact hit: কিছুই নামাতে হলো না।
  3. Lockfile বদল। Developer tabulate==0.9.0 যোগ করলেন। মাত্র একটি লাইন, কিন্তু hash সম্পূর্ণ আলাদা: 613d1caef20a. Hash মিল বা মিল-না, মাঝামাঝি কিছু নেই।
  4. Exact miss, তারপর prefix. নতুন key store-এ নেই। Restore key linux-pip- prefix দিয়ে সবচেয়ে নতুন কাছের cache খোঁজে, আর পুরোনো aba1…-কে পায়। এটি partial hit.
  5. Install পুরো চলে। Partial hit-এ install বাদ যায় না। pip ১২টি package cache থেকে নিল, শুধু tabulate নামাল। আসল log-এও ঠিক তাই ছিল। Job শেষে নতুন key-তে জমা, store-এ এখন দুটি key.
  6. পরের run. আবার শুধু কোড বদল। নতুন key-তে exact hit, ১৩টিই cache থেকে। Key বদলায় কেবল dependency বদলালে, এটিই ভালো key-এর লক্ষণ।

ভালো key-তে থাকে OS, CPU-র ধরন, ভাষার version আর lockfile-এর hash. Key-তে OS না থাকলে Linux-এর compiled package Windows-এ ফিরে আসতে পারে। Key-তে তারিখ বা commit SHA রাখলে প্রায় সবসময় miss, মানে cache hit ratio শূন্যের কাছে।

আসল Docker build: কত সময় বাঁচে

একটি ছোট Python app (Flask, requests আর তাদের dependency মিলিয়ে ১২টি pin, image ৯৯.৫ MB) নিয়ে Docker Engine 29 আর BuildKit-এ আসল build চালানো হয়েছে। সময় মাপা হয়েছে build-এর বাইরের stopwatch দিয়ে। আপনার ক্ষেত্রে id, digest, আকার আর সময় আলাদা হবে; এই run-এ network দ্রুত ছিল, আর package-গুলো ছোট।

FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --disable-pip-version-check --root-user-action=ignore -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]

ক্রমটিই আসল কৌশল: আগে শুধু lockfile, তারপর install, শেষে কোড। তাই কোড বদলালে install-এর layer ছোঁয়া হয় না। Cache mount-এর সংস্করণে শুধু RUN লাইনটি বদলে যায়: RUN --mount=type=cache,id=abcl-pd-pip,target=/root/.cache/pip pip install …, আর --no-cache-dir বাদ পড়ে। “খারাপ” সংস্করণে COPY . . থাকে install-এর আগে।

শুধু app.py বদলে দুই Dockerfile-এর আসল আউটপুট, ছোট করে:

$ docker build --progress=plain -f Dockerfile.plain -t abcl-pd-app:plain .
…
#6 [2/5] WORKDIR /app
#6 CACHED

#7 [3/5] COPY requirements.txt .
#7 CACHED

#8 [4/5] RUN pip install --no-cache-dir --disable-pip-version-check --root-user-action=ignore -r requirements.txt
#8 CACHED

#9 [5/5] COPY app.py .
#9 DONE 0.1s
…

তিনটি ধাপ CACHED, শুধু শেষ COPY সত্যি চলেছে। মোট ১ সেকেন্ডের কাছাকাছি।

$ docker build --progress=plain -f Dockerfile.bad -t abcl-pd-app:bad .
…
#6 [2/4] WORKDIR /app
#6 CACHED

#7 [3/4] COPY . .
#7 DONE 0.0s

#8 [4/4] RUN pip install --no-cache-dir --disable-pip-version-check --root-user-action=ignore -r requirements.txt
…
#8 DONE 4.1s
…

একই বদল, কিন্তু COPY . .-এর input বদলে যাওয়ায় তার পরের RUN-ও আবার চলেছে। একটি লাইনের ভুল জায়গায় প্রতি commit-এ প্রায় ৫ সেকেন্ড নষ্ট।

পরিস্থিতি (আসল run)কতবারসময় (সেকেন্ড)
কোড বদল, ভালো ক্রম, layer cache আছে৩0.89 – 1.02
কোড বদল, খারাপ ক্রম৩5.88 – 6.41
--no-cache, সব আবার৩6.25 – 8.16
Lockfile-এ এক pin যোগ, pip cache নেই৪5.09 – 6.16
Lockfile-এ এক pin যোগ, cache mount আছে৪4.44 – 5.44
নতুন builder, কোনো cache নেই৩17.27 – 17.82
নতুন builder, --cache-from (cache ২৪ MB)৩7.46 – 7.89

এই টেবিল থেকে তিনটি সৎ শিক্ষা:

  • ক্রমের লাভই সবচেয়ে বড়। এক লাইন সরিয়ে প্রায় ৫ সেকেন্ড বাঁচে, প্রতি commit-এ, এক পয়সা খরচ ছাড়া।
  • Cache mount এখানে সামান্যই বাঁচিয়েছে, গড়ে ০.৪ সেকেন্ডের মতো। Network দ্রুত, wheel ছোট, আর install-এর সময় ঠিকই লাগে। তবে আগের একটি run-এ cache ছাড়া একই ধরনের build ১৭.৮৩ সেকেন্ড নিয়েছিল, আর cache mount-সহ ৪.৪১ সেকেন্ড; কারণ এখানে যাচাই করা হয়নি, সম্ভবত network-এর ওঠানামা। ধীর network-এ বা বড় package-এ এর লাভ বাড়ার কথা। অনুমান নয়, নিজের pipeline-এ মেপে দেখুন।
  • নতুন builder-ই hosted runner-এর আসল ছবি। এই সারিগুলোর প্রতিটি run একটি একেবারে নতুন builder-এ চলেছে, ঠিক খালি runner-এর মতো। --cache-from সময় অর্ধেকের কম করেছে: ১৭.৫ থেকে ৭.৫ সেকেন্ড।
$ docker buildx build --builder abcl-pd-builder --progress=plain -f Dockerfile.plain \
    --cache-from type=local,src=../ccache -t abcl-pd-app:fresh --load .
…
#4 importing cache manifest from local:15948370437373171322
#4 inferred cache manifest type: application/vnd.oci.image.manifest.v1+json done
#4 DONE 0.0s
…
#7 [2/5] WORKDIR /app
#7 CACHED

#8 [3/5] COPY requirements.txt .
#8 CACHED

#9 [4/5] RUN pip install --no-cache-dir --disable-pip-version-check --root-user-action=ignore -r requirements.txt
…
#9 CACHED
…

Builder একেবারে নতুন, তার নিজের কোনো cache নেই, তবু pip install-এর ধাপ CACHED. Layer এসেছে আগের run-এ --cache-to type=local,dest=../ccache,mode=max দিয়ে জমানো folder থেকে। আসল pipeline-এ এই জায়গা হয় registry (type=registry) বা platform-এর cache (type=gha). mode=max মধ্যবর্তী ধাপের layer-ও জমায়, যা multi-stage build-এ কাজের।

দুটি ফাঁদ, দুটিই আসল run-এ দেখা। প্রথম, --no-cache দিলে এই run-গুলোতে cache mount-ও কাজে লাগেনি: pip সব আবার নামিয়েছে, তিনবারই। দ্বিতীয়, cache mount থাকে builder-এর ভেতরে, image-এ বা --cache-to-র export-এ নয়। তাই hosted runner-এ, যেখানে প্রতিবার নতুন builder, আলাদা ব্যবস্থা ছাড়া cache mount-এর লাভ পাওয়া যায় না। সে কারণে self-hosted runner-এ cache mount, আর hosted runner-এ --cache-from বেশি মানায়।

Cache hit ratio বনাম খরচ

Cache কি সবসময় লাভ? না। Cache ফেরাতে আর জমাতেও সময় লাগে। নিচের মডেলে সব দাম ধরে নেওয়া: ধরে নিই runner-মিনিট $০.০০৮, মাসে ৩,০০০ run, প্রতি run-এ ৬টি job. আপনার provider-এর আসল দাম তার pricing পাতায় দেখুন।

// Cache hit ratio vs runner minutes and monthly bill. All prices ASSUMED.
const RUNS = 3000, JOBS = 6;              // per month, per run
const WORK = 5, INSTALL = 4;              // minutes per job
const RESTORE_HIT = 0.5;                  // download and unpack the cache
const MISS_EXTRA = 0.5;                   // failed lookup + saving a new cache
const PRICE = 0.008;                      // $ per runner-minute (assumed)
const row = m => ({ m, total: m * JOBS * RUNS, bill: m * JOBS * RUNS * PRICE });
const none = row(WORK + INSTALL);
console.log(`no cache: ${none.m.toFixed(2)} min/job, ` +
  `${none.total} min/month, $${none.bill.toFixed(0)}`);
for (const h of [0, 0.25, 0.5, 0.75, 0.9, 0.95, 1]) {
  const m = WORK + h * RESTORE_HIT + (1 - h) * (INSTALL + MISS_EXTRA);
  const r = row(m);
  console.log(`hit ${String(h * 100).padStart(3)}%: ${m.toFixed(2)} min/job  ` +
    `${String(Math.round(r.total)).padStart(6)} min/month  ` +
    `$${r.bill.toFixed(0).padStart(4)}  saves $${(none.bill - r.bill).toFixed(0)}`);
}
const be = MISS_EXTRA / (INSTALL + MISS_EXTRA - RESTORE_HIT);
console.log(`break-even hit ratio: ${(be * 100).toFixed(1)}%`);

আউটপুট:

no cache: 9.00 min/job, 162000 min/month, $1296
hit   0%: 9.50 min/job  171000 min/month  $1368  saves $-72
hit  25%: 8.50 min/job  153000 min/month  $1224  saves $72
hit  50%: 7.50 min/job  135000 min/month  $1080  saves $216
hit  75%: 6.50 min/job  117000 min/month  $ 936  saves $360
hit  90%: 5.90 min/job  106200 min/month  $ 850  saves $446
hit  95%: 5.70 min/job  102600 min/month  $ 821  saves $475
hit 100%: 5.50 min/job   99000 min/month  $ 792  saves $504
break-even hit ratio: 12.5%

Hit ratio h হলে প্রতি job-এ সময় = ৫ + ০.৫h + ৪.৫(১ − h). Cache না থাকলে ৯ মিনিট। সমান ধরলে ০.৫h + ৪.৫ − ৪.৫h = ৪, তাই h = ০.৫ ÷ ৪ = ১২.৫%. এর নিচে cache উল্টো ক্ষতি করে, যেমন key-তে commit SHA রাখলে। এই মডেলে লাভ hit ratio-র সাথে সরলরেখায় বাড়ে, প্রতি ১ শতাংশ বিন্দুতে মাসে $৫.৭৬: ৫০% থেকে ৯৫%-এ গেলে $২৫৯ বাঁচে, শেষ ৫ বিন্দুতে $২৯। আর টাকার চেয়েও বড় কথা, ১০০% hit-এ প্রতি job-এ ৩.৫ মিনিট কম মানে মানুষের কম অপেক্ষা, যদি সেই job critical path-এ থাকে।

Cache poisoning: গতির বদলে বিশ্বাস হারানো

Cache হলো এক run-এর লেখা file, যা পরের run বিনা প্রশ্নে চালায় বা ব্যবহার করে। অবিশ্বস্ত কোড যদি সেই cache-এ লিখতে পারে, আর বিশ্বস্ত release job তা পড়ে, তাহলে ক্ষতিকর জিনিস সবুজ pipeline-এর ভেতর দিয়ে production-এ পৌঁছায়। এটিই cache poisoning. গুদামঘরের উদাহরণে: মাল ফেরত নেওয়ার তাক আর বিক্রির তাক এক হলে একটি নকল প্যাকেট আসল মালের সারিতে ঢুকে যায়। প্রতিরক্ষার সিদ্ধান্তটি ধাপে ধাপে দেখুন।

প্রতিটি ধাপে প্রশ্ন আর সিদ্ধান্ত
প্রশ্ন
উত্তর
সিদ্ধান্ত
  1. শুরু: কে লিখছে? একটি fork থেকে pull request এসেছে। তার কোড job-এর ভেতরে চলছে, আর job শেষে cache জমাতে চায়। সিদ্ধান্তের প্রথম প্রশ্ন গতি নয়, পরিচয়।
  2. Ref-এর বিশ্বাস। Fork-এর কোড অবিশ্বস্ত। তাই main বা release-এর পড়ার cache-এ সে লিখতে পারবে না।
  3. আলাদা সীমা (cache scope). GitHub Actions-এ লেখার সময়ে cache branch ধরে আলাদা: PR-এর লেখা cache মূল branch পড়ে না, তবে PR মূল branch-এর cache পড়তে পারে। অন্য platform-এর নিয়ম আলাদা, documentation দেখুন।
  4. পড়ার দিক। বিশ্বস্ত run শুধু বিশ্বস্ত সীমার cache পড়ে। তবে মনে রাখুন, main-এ চলা যেকোনো workflow যদি বাইরের ইনপুটে কোড চালায়, সে-ও সেই সীমায় লিখতে পারে। তাই সীমা একমাত্র দেয়াল নয়।
  5. পড়ার পরে যাচাই। Hash-সহ lockfile (npm ci lockfile-এর integrity মেলায়, pip install --require-hashes hash মেলায়) নষ্ট বা বদলানো package ধরতে পারে। Cache থেকে সরাসরি compiled tool চালানো সবচেয়ে ঝুঁকির।
  6. Release আলাদা। যে build production-এ যাবে বা package প্রকাশ করবে, সে অবিশ্বস্ত run-এর cache-এ ভরসা না করে পরিষ্কার পরিবেশে চলে। সেখানে সময়ের চেয়ে নিশ্চয়তা বড়। বাড়তি প্রমাণের জন্য build provenance, পর্ব ৬১-তে (DevSecOps).

৬ · Parallel, matrix, sharding আর Amdahl-এর সীমা

দুই ধরনের parallel আলাদা করে চিনুন। Matrix build একই test ভিন্ন পরিবেশে চালায়, যেমন Python 3.11, 3.12, 3.13 × Linux, Windows = ৬টি job. এটি বাড়তি আত্মবিশ্বাস কেনে, সময় নয়। Test sharding একই পরিবেশে একটি বড় suite ভেঙে কয়েকটি runner-এ চালায়। এটি সময় কেনে।

হাতে হিসাব। Suite ২০ মিনিট, প্রতিটি runner-এর setup ২ মিনিট, ৪টি shard, ভাগ নিখুঁত। সময় = ২ + ২০ ÷ ৪ = ৭ মিনিট। Runner-মিনিট = ৪ × ২ + ২০ = ২৮, যেখানে এক runner-এ ছিল ২২। অর্থাৎ সময় ৬৮% কমল, বিল ২৭% বাড়ল. নিখুঁত ভাগ কিন্তু সহজ নয়। ধরা যাক ৪০টি test file, যার প্রথম ৮টি database-এর ধীর test. শুধু সংখ্যা সমান করে ভাগ করলে সব ধীর test এক shard-এ পড়ে।

// Split 40 test files into 4 shards: naive chunks vs balanced by time.
function mulberry32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0;
  let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
  return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
const rnd = mulberry32(57), SHARDS = 4, SETUP = 60;       // seconds
const tests = [];                                         // folder order
for (let i = 0; i < 8; i++)  tests.push(40 + Math.floor(rnd() * 50));  // db: slow
for (let i = 0; i < 32; i++) tests.push(5 + Math.floor(rnd() * 16));   // unit
const load = s => s.reduce((a, b) => a + b, 0), total = load(tests);
const empty = k => Array.from({ length: k }, () => []);
function naive(t, k) {                       // equal COUNT, in folder order
  const s = empty(k), n = Math.ceil(t.length / k);
  t.forEach((d, i) => s[Math.floor(i / n)].push(d));
  return s;
}
function balanced(t, k) {                    // longest first, to the lightest shard
  const s = empty(k);
  [...t].sort((a, b) => b - a).forEach(d =>
    s.reduce((m, x) => load(x) < load(m) ? x : m).push(d));
  return s;
}
function report(name, shards) {
  const loads = shards.map(load), wall = SETUP + Math.max(...loads);
  console.log(`${name.padEnd(9)} loads ${loads.join(" ")}  wall ${wall}s` +
    `  runner-sec ${loads.length * SETUP + total}`);
  return wall;
}
console.log(`40 files, ${total}s of tests, one runner: ${SETUP + total}s`);
const a = report("naive", naive(tests, SHARDS)), b = report("balanced", balanced(tests, SHARDS));
console.log(`speedup: naive ${((SETUP + total) / a).toFixed(2)}x, balanced ${((SETUP + total) / b).toFixed(2)}x`);
const p = total / (SETUP + total);                       // Amdahl's limit
for (const n of [2, 4, 8, 16, 1000])
  console.log(`n=${String(n).padEnd(5)} Amdahl limit ${(1 / ((1 - p) + p / n)).toFixed(2)}x`);

আউটপুট:

40 files, 880s of tests, one runner: 940s
naive     loads 516 131 118 115  wall 576s  runner-sec 1120
balanced  loads 220 221 219 220  wall 281s  runner-sec 1120
speedup: naive 1.63x, balanced 3.35x
n=2     Amdahl limit 1.88x
n=4     Amdahl limit 3.36x
n=8     Amdahl limit 5.53x
n=16    Amdahl limit 8.17x
n=1000  Amdahl limit 15.44x

দুই ভাগেই runner-সেকেন্ড ১,১২০, মানে বিল একই। কিন্তু naive ভাগে প্রথম shard ৫১৬ সেকেন্ড চলে, বাকি তিনটি অলস বসে থাকে, গতি মাত্র ১.৬৩ গুণ। Balanced ভাগ (লম্বাটি আগে, সবচেয়ে হালকা shard-কে) একই টাকায় দ্বিগুণের বেশি ফল দেয়। এই বাস্তব কৌশলের জন্য আগের run-এর সময় লাগে, তাই অনেক tool timing file রাখে।

এবার শেষ পাঁচ লাইন। ১৯৬৭ সালে Gene Amdahl দেখান: কাজের যে অংশ ভাগ করা যায় না, সে-ই গতির ছাদ ঠিক করে। ভাগ করা যায় এমন অংশ p, runner n হলে:

গতি = 1 ÷ ((1 − p) + p ÷ n) ≤ 1 ÷ (1 − p)

এখানে p = ৮৮০ ÷ ৯৪০ ≈ ০.৯৩৬, তাই ছাদ ৯৪০ ÷ ৬০ ≈ ১৫.৭ গুণ। ১,০০০ runner-এও ১৫.৪৪ গুণ, অথচ ১৬ runner-ই দেয় ৮.১৭ গুণ। এই সরল মডেলে setup-কে ভাগ-না-হওয়া অংশ ধরা হয়েছে, কারণ প্রতিটি shard-কে পুরো setup-টাই করতে হয়। আরেকটি সীমা: একটি test file ভাঙা না গেলে shard তার চেয়ে ছোট হতে পারে না। এখানে সবচেয়ে ধীর file ৭৬ সেকেন্ডের, তাই n যত বড়ই হোক, সময় ৬০ + ৭৬ = ১৩৬ সেকেন্ডের নিচে যাবে না। এটিও critical path-এর আরেক রূপ।

আরও গভীরে: DAG-এর সময়সূচি বের করা গণিতে NP-hard, কিন্তু Ron Graham ১৯৬৬ সালে দেখান, যেকোনো list scheduling-এর ফল সেরা সময়ের (2 − 1/m) গুণের বেশি হয় না, যেখানে m হলো যন্ত্রের সংখ্যা। ১৯৬৯ সালের লেখায় তিনি দেখান, “লম্বাটি আগে” নিয়মে (স্বাধীন কাজের ক্ষেত্রে) সীমা আরও কাছে: 4/3 − 1/(3m). এই পর্বের balanced ভাগ সেই নিয়মই। Critical path method নিজে এসেছে ১৯৫০-এর দশকের শেষের প্রকল্প-পরিকল্পনার কাজ থেকে (Kelley ও Walker), প্রায় একই সময়ে মার্কিন নৌবাহিনীর PERT. Amdahl-এর পরিপূরক ভাবনা আছে Gustafson-এর ১৯৮৮ সালের লেখায়: সময় স্থির রেখে কাজ বাড়ালে ছবিটি অন্য রকম।

৭ · Flaky test, monorepo আর পুরোনো run বাতিল

Flaky test: যে test মিথ্যা বলে

Flaky test একই কোডে কখনো পাস, কখনো ফেল। একটি test-এর ফেলের সম্ভাবনা সামান্য হলেও suite-এ তা জমে যায়। ধরুন ২,০০০টি test, প্রতিটি ০.১% সময় মিথ্যা ফেল করে। সব পাস হওয়ার সম্ভাবনা ০.৯৯৯২০০০ ≈ ০.১৩৫, মানে প্রায় ৮৬.৫% run অকারণে লাল. একবার retry দিলে একটি test-এর দুবার ফেল ০.০০১² = ১০⁻⁶, তখন মিথ্যা লাল প্রায় ০.২%. Retry তাই গণিতে দারুণ, কিন্তু ঝুঁকি হলো আসল race condition-ও ঢাকা পড়ে।

ভালো পথের চারটি ধাপ: চিহ্নিত করুন (retry-তে পাস করা প্রতিটি test log-এ তুলে রাখুন), quarantine করুন (আলাদা non-blocking দলে সরান, যাতে অন্যদের আটকে না রাখে), মালিক ঠিক করুন আর সময়সীমা দিন, আর কারণ সরান: বেশিরভাগ ক্ষেত্রে async অপেক্ষা, ভাগ করা অবস্থা, সময় বা ক্রমের ওপর নির্ভরতা। Retry-র সীমা থাকুক, আর সেই সংখ্যাটি মেট্রিক হোক। Test নকশার গভীর আলোচনা Test pyramid আর integration test পর্বে।

Monorepo-র path filter

একটি monorepo-তে অনেক service এক repository-তে। Docs-এর একটি শব্দ বদলালে সব service-এর ৪০ মিনিটের test চালানো অপচয়। Path filter বলে দেয় কোন file বদলালে কোন কাজ চলবে: GitHub Actions-এ on.pull_request.paths, GitLab-এ rules:changes. Trade-off আর graph-ভিত্তিক tool Monorepo পর্বে আছে।

  • ভাগ করা কোড: libs/payment/ বদলালে যে সব service তা ব্যবহার করে, তাদেরও চলতে হবে। শুধু path দেখলে সেটি ধরা পড়ে না। কে কাকে ব্যবহার করে, তা জানতে dependency graph লাগে, যেমন Bazel, Nx বা Turborepo-র.
  • Required check-এর ফাঁদ: GitHub Actions-এ path filter-এ পুরো workflow বাদ পড়লে তার check, documentation অনুযায়ী, “Pending” থেকে যায়, আর সেটি required check হলে merge আটকে থাকে। প্রচলিত সমাধান: workflow সবসময় চলে, ভেতরের job শর্তে চলে, আর শেষে একটি “gate” job if: always() দিয়ে সবার ফল মিলিয়ে দেখে।
  • নিরাপত্তার জাল: মিস হওয়া নির্ভরতা ধরতে main-এ বা রাতে মাঝে মাঝে পুরো suite চালান।

Concurrency: পুরোনো run বাতিল

ধরুন আপনি ২৫ মিনিটের pipeline চলার মাঝে একই branch-এ নতুন commit পাঠালেন। পুরোনো run-এর ফল আর কারও কাজে লাগবে না, তবু সে runner দখল করে আছে। GitHub Actions-এ concurrency-র cancel-in-progress, GitLab-এ interruptible: true আর auto-cancel তাকে থামায়। ঈদের আগে বাসের টিকিটের লাইনে একই মানুষের দুটি পুরোনো আবেদন বাতিল করে নতুনটি রাখার মতো।

কিন্তু deploy কখনো মাঝপথে বাতিল করা ঠিক নয়। অর্ধেক হওয়া deploy আধখানা migration রেখে যেতে পারে। Deploy-এ উল্টো নিয়ম: একই environment-এ একবারে একটি, পরেরটি লাইনে অপেক্ষা করে। GitHub-এ cancel-in-progress: false, GitLab-এ resource_group.

৮ · Pipeline as code: secret, fork আর দুটি YAML

Secret আর fork-এর pull request

Secret-এর মূল নিয়ম পর্ব ৩৩-এ আছে। Pipeline-এর জন্য চারটি অভ্যাস যথেষ্ট:

  • Secret আসে platform-এর store থেকে, শুধু যে job-এর দরকার তাকে, সবচেয়ে ছোট পরিসরে (environment). Log-এ masking থাকলেও echo নয়, কারণ রূপ বদলালে ঢাকা পড়ে না।
  • স্থায়ী cloud key-এর বদলে OIDC দিয়ে স্বল্পমেয়াদি পরিচয়; বিস্তারিত পর্ব ৬১-তে (DevSecOps).
  • স্বয়ংক্রিয় token-এর ডিফল্ট অনুমতি contents: read, বাড়তি শুধু দরকারি job-এ।
  • Fork থেকে আসা pull request-এর সাধারণ event-এ secret দেওয়া হয় না (GitHub Actions, লেখার সময়ে)। বিপদ pull_request_target-এ, যা base repository-র অধিকারে চলে। সেখানে PR-এর কোড checkout করে চালানো যাবে না। আরও পড়ুন CI পর্বের নিরাপত্তা অংশে।

GitHub Actions-এ পুরো নকশা

নিচের YAML কাল্পনিক bazar-app-এর। এটি চালানো হয়নি, শুধু গঠন দেখানোর জন্য; parser দিয়ে যাচাই করা হয়েছে। Action-এর major version লেখার সময়ের (অক্টোবর ২০২৬) tag দেখে লেখা, ব্যবহারের আগে মিলিয়ে নিন; পেশাদার ব্যবহারে পুরো commit SHA দিয়ে pin করুন।

# .github/workflows/ci.yml  (fictional bazar-app; illustration, not run here)
name: ci
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read                     # least privilege by default
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  lint:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v7
      - run: pipx run ruff check .

  unit:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false               # show every failing shard
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/cache@v6
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-
      - run: pip install -r requirements.txt pytest pytest-split
      - run: pytest --splits 4 --group ${{ matrix.shard }}

  image:
    needs: [lint, unit]              # DAG edge
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/build-push-action@v7
        with:
          context: .
          push: false
          tags: bazar-app:${{ github.sha }}
          cache-from: type=gha
          # Only main may write the shared layer cache.
          cache-to: ${{ github.ref == 'refs/heads/main' && 'type=gha,mode=max' || '' }}
  • concurrency: pull request-এ পুরোনো run বাতিল, main-এ নয়।
  • unit-এর matrix: চারটি shard. pytest-split একটি তৃতীয় পক্ষের plugin, যা জমা রাখা সময় দেখে ভাগ করে; timing file না থাকলে ভাগ ততটা সমান নয়।
  • key আর restore-keys: stepper-এ দেখা সেই exact hit আর partial hit.
  • needs: [lint, unit]: চার shard-ই সবুজ হলে তবে image.
  • cache-to: শুধু main shared layer cache-এ লেখে, এটি poisoning-এর ঝুঁকি কমায়। ফাঁকা মানে কোনো export নেই।

GitLab CI-তে একই নকশা

# .gitlab-ci.yml  (the same fictional design; illustration, not run here)
stages: [check, package]

workflow:
  auto_cancel:
    on_new_commit: interruptible     # cancel older pipelines of this ref
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

default:
  image: python:3.12-alpine
  interruptible: true

lint:
  stage: check
  script:
    - pip install ruff
    - ruff check .

unit:
  stage: check
  parallel: 4                        # CI_NODE_INDEX and CI_NODE_TOTAL per job
  variables:
    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
  cache:
    key:
      files: [requirements.txt]
      prefix: pip
    paths: [.pip-cache/]
  script:
    - pip install -r requirements.txt pytest pytest-split
    - pytest --splits "$CI_NODE_TOTAL" --group "$CI_NODE_INDEX"

build-image:
  stage: package
  needs: [lint, unit]
  rules:
    - changes: [Dockerfile, requirements.txt, "app/**/*"]
  script:
    - echo "build the image here (runner-specific, see the docs)"

GitLab-এ parallel: 4 নিজেই চারটি job বানায়, আর প্রতিটি নিজের নম্বর পায়। cache:key:files lockfile ধরে key বানায়। interruptible আর auto_cancel পুরোনো pipeline থামায়; এই keyword-গুলোর আচরণ version-ভেদে আলাদা হতে পারে, তাই documentation মিলিয়ে নিন। Job-এর নাম build-image, কারণ image GitLab-এ সংরক্ষিত শব্দ, ওই নামে job রাখা যায় না। এর শেষ লাইনটি ইচ্ছা করেই ফাঁকা, কারণ GitLab-এ পথটি runner-এর সেটআপের ওপর নির্ভর করে।

Reusable workflow আর template

৫০টি repository-তে একই YAML কপি থাকলে একটি নিরাপত্তা-সংশোধন ৫০ জায়গায় করতে হয়। এই কপির সমস্যা মেটায় reusable workflow: GitHub Actions-এ on: workflow_call, GitLab-এ include, extends আর CI/CD component. রান্নাঘরের একটি রেসিপি খাতা থাকলে সবাই একই বিরিয়ানি রাঁধেন।

# A service repository calls the shared workflow (fictional organisation).
name: service-ci
on:
  pull_request:
jobs:
  python-ci:
    uses: example-org/ci-templates/.github/workflows/python-ci.yml@v2
    with:
      python-version: "3.12"
    permissions:
      contents: read

Service-টি শুধু বলে কোন template, কোন version. "3.12" উদ্ধৃতিচিহ্নে, নইলে YAML একে সংখ্যা ভাবতে পারে। ঝুঁকিটিও মনে রাখুন: template মানে কেন্দ্রীয় ক্ষমতা। একটি ভুল সব repository-তে যায়, তাই template-কেও version, test আর review-এর মধ্যে রাখা হয়। এটিই পর্ব ৬২-র (Platform engineering) golden path-এর একটি রূপ।

৯ · মাপা: queue time, duration, success rate আর খরচ

গড় সময় মিথ্যা বলে, কারণ pipeline-এর সময় একদিকে হেলানো। তাই p50 আর p90 দেখুন। চারটি মেট্রিক সবচেয়ে কাজের:

মেট্রিককী বলেখারাপ হলে পরের কাজ
Queue timeRunner পেতে অপেক্ষাRunner বা capacity বাড়ান, concurrency দেখুন
Duration p50, p90কাজের সময়Critical path, cache, sharding
Success rateকত run সবুজ, বাতিল বাদেআসল ভুল, নাকি flaky?
Cost per runRunner-মিনিট × দাম ÷ runঅপ্রয়োজনীয় matrix, path filter

নিচের Bash block একটি নমুনা CSV থেকে এই চারটি বের করে। সংখ্যাগুলো বানানো উদাহরণ, আর দাম ধরে নেওয়া।

#!/bin/bash
# Pipeline health from a sample run log. Price per runner-minute is ASSUMED.
set -euo pipefail
cat > runs.csv <<'CSV'
id,queue_s,duration_s,result,runner_min
101,20,1590,success,45
102,35,1640,success,46
103,15,240,failure,8
104,410,1610,success,45
105,30,1980,failure,52
106,25,300,cancelled,9
107,620,1600,success,45
108,40,1655,success,46
109,20,1625,success,45
110,55,2400,success,70
CSV
pct() {  # pct COLUMN PERCENT : nearest-rank percentile
  tail -n +2 runs.csv | cut -d, -f"$1" | sort -n |
    awk -v p="$2" '{ v[NR] = $1 }
      END { r = p * NR / 100; i = int(r); if (i < r) i++; print v[i] }'
}
echo "queue    p50 $(pct 2 50)s  p90 $(pct 2 90)s"
echo "duration p50 $(pct 3 50)s  p90 $(pct 3 90)s"
awk -F, -v price=0.008 'NR > 1 { c[$4]++; m += $5; n++ }
  END {
    done = c["success"] + c["failure"]
    printf "runs %d, cancelled %d\n", n, c["cancelled"]
    printf "success rate %.1f%%\n", 100 * c["success"] / done
    printf "runner minutes %d, cost $%.2f\n", m, m * price
    printf "cost per run $%.3f, per green run $%.3f\n", m * price / n, m * price / c["success"]
  }' runs.csv

আউটপুট:

queue    p50 30s  p90 410s
duration p50 1610s  p90 1980s
runs 10, cancelled 1
success rate 77.8%
runner minutes 411, cost $3.29
cost per run $0.329, per green run $0.470

Queue-এর p50 মাত্র ৩০ সেকেন্ড, কিন্তু p90 ৪১০ সেকেন্ড, মানে ব্যস্ত সময়ে মানুষ প্রায় ৭ মিনিট বসে থাকেন। গড় একে লুকিয়ে ফেলত। ব্যর্থ আর বাতিল run-এর টাকাও বিলে আছে, তাই একটি সবুজ run-এর আসল দাম $০.৩২৯ নয়, $০.৪৭০. Success rate-এ বাতিল run বাদ দেওয়া হয়েছে, কারণ বাতিল হওয়া ব্যর্থতা নয়।

এখানে একটি খরচ বিলে দেখা যায় না: মানুষের অপেক্ষা। আট জনের দলে দিনে ২০টি run, প্রতি run-এ ১০ মিনিট কমলে দিনে ২০০ মিনিট মনোযোগ ফেরে। DORA-র মেট্রিকের সাথে এর সম্পর্ক Continuous delivery ও DevOps পর্বে আছে, আর বিলের দিক পর্ব ৭৩-এ (FinOps).

পরীক্ষা ও ইন্টারভিউতে:

  • Stage আর job-এর পার্থক্য? Stage job-এর দল, ক্রম ঠিক করে; job runner-এ চলে; step job-এর ভেতরে পরপর।
  • Runner বাড়িয়েও সময় কমছে না, কেন? সময় critical path-এ আটকে আছে, runner-এর লাইনে নয়।
  • Cache আর artifact-এর পার্থক্য? Cache গতির জন্য, না থাকলেও ফল এক; artifact ফল হস্তান্তর করে, না থাকলে পরের job চলে না।
  • ২০ মিনিটের suite, setup ২, ৪ shard: সেরা সময়? ২ + ৫ = ৭ মিনিট, ২৮ runner-মিনিট।
  • Public repository-তে self-hosted runner কেন ঝুঁকির? বাইরের pull request-এর কোড আপনার স্থায়ী যন্ত্রে চলতে পারে।

১০ · AI-এর যুগে pipeline লেখা

AI কয়েক সেকেন্ডে পুরো YAML লিখে দেয়, আর দেখতে একদম ঠিক লাগে। কিন্তু pipeline-এর হাতে secret আর production-এর পথ থাকে, তাই ভুলের দাম বেশি। AI যেসব ভুল প্রায়ই করে:

AI-এর ভুলঝুঁকিকী করবেন
permissions: write-all বা ডিফল্টে ছেড়ে দেওয়াToken ফাঁসে অনেক ক্ষতিcontents: read দিয়ে শুরু
Key বা password সরাসরি YAML-এGit-এ secretSecret store, OIDC
নেই এমন action বা inputBuild ভাঙে, অথবা নকল নামের action চলেAction-এর repository আর docs দেখুন
uses: …@main, latest imageSupply chain, দুই run-এর ফল আলাদাCommit SHA, digest (পর্ব ৪১)
pull_request_target-এ PR-এর কোড চালানোবাইরের কোড secret পায়আলাদা workflow, কোড না চালানো
Cache key-তে commit SHAপ্রায় শূন্য hit, উল্টো খরচLockfile-এর hash, hit ratio মাপা
Deploy job-এ cancel-in-progress: trueআধখানা deployDeploy-তে লাইনে অপেক্ষা
Matrix ফুলিয়ে দেওয়া, timeout নেইবিল বাড়ে, আটকে থাকা jobJob গুনুন, timeout-minutes
সতর্কতা ছাড়া rm -rf, terraform destroy, kubectl deleteData হারায়আগে plan বা --dry-run, মানুষের অনুমোদন

AI-কে pipeline review করতে দিন, আর উত্তরের সাথে যাচাইয়ের পথও চান। আসল secret, token বা internal URL prompt-এ দেবেন না।

তুমি একজন সতর্ক DevOps reviewer. নিচের CI workflow দেখো (platform: GitHub Actions, Python app, repository public, প্রতিটি run-এ গড়ে ২০ মিনিট): [YAML পেস্ট করুন; secret আর internal URL সরিয়ে] ১. job-গুলোর DAG আঁকো, প্রতিটির সময়ের অনুমান দিয়ে critical path বলো। ২. কোন job সরালে বা ভাগ করলে আসলে সময় কমবে, আর কোনটায় কমবে না? ৩. cache key, restore key আর cache-এ লেখার অধিকার ঠিক আছে কি? poisoning-এর পথ আছে কি? ৪. fork-এর pull request-এ কোনো secret বা write token পৌঁছাতে পারে কি? ৫. কোন action, input বা version নিয়ে তুমি নিশ্চিত নও, তা আলাদা করে লেখো। ৬. খরচ বাড়ায় এমন কিছু থাকলে runner-মিনিটের হিসাবসহ দেখাও। কিছু মুছে ফেলার মতো step থাকলে আলাদা করে সতর্ক করো।

AI-এর লেখা pipeline চালানোর আগে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১Critical path-এর ধারণা CI-এর অনেক আগের। একটি দল runner দ্বিগুণ করল, তবু pipeline-এর সময় প্রায় একই। এমনকি কখনো বেড়ে যেতে পারে, এটি কীভাবে সম্ভব?

ধারণাটি এসেছে প্রকল্প-পরিকল্পনা থেকে। ১৯৫০-এর দশকের শেষে DuPont আর Remington Rand-এর সাথে কাজ করে James Kelley ও Morgan Walker যে পদ্ধতি দাঁড় করান, তা Critical Path Method নামে পরিচিত। প্রায় একই সময়ে মার্কিন নৌবাহিনীর Polaris প্রকল্পে PERT আসে। প্রশ্ন একই ছিল: হাজারো কাজের মধ্যে কোন কাজ দেরি হলে পুরো প্রকল্প দেরি হবে? একটি বহুতল ভবনের ছাদ ঢালাই দেয়াল ওঠার আগে হয় না, যত রাজমিস্ত্রিই আনুন।

Pipeline-এ runner হলো রাজমিস্ত্রি। সময় আটকে থাকলে দুই জায়গায় আটকে থাকে: runner-এর লাইনে, অথবা নির্ভরতার শিকলে। প্রথমটিতে runner বাড়ালে সময় কমে। দ্বিতীয়টিতে কমে না। দলটির queue time আগেই শূন্যের কাছে থাকলে দ্বিগুণ runner শুধু বিল বাড়াবে, যেমন simulator-এ চতুর্থ runner.

সময় বেড়ে যাওয়ার ঘটনাটি আরও মজার। Ron Graham ১৯৬০-এর দশকে “scheduling anomaly” দেখান: নির্দিষ্ট DAG আর list scheduling-এ যন্ত্র বাড়ালে, বা কাজের সময় কমালে, বা নির্ভরতা সরালে মোট সময় বাড়তে পারে। কারণ ক্রম বদলে একটি লম্বা কাজ ভুল সময়ে যন্ত্র দখল করে। বাস্তব pipeline-এ এমন ঘটনা বিরল, তবে তার ছোট ভাই সাধারণ: বেশি job একসঙ্গে চলায় shared cache বা registry-তে ভিড়, database-এ চাপ, API-এর rate limit. Parallel বাড়াতেই প্রতিটি job ধীর হয়।

তাই ঠিক ক্রমটি এই: আগে queue time আর critical path মাপুন। Queue বড় হলে capacity. Queue ছোট হলে পথের job ভাঙুন বা দ্রুত করুন। প্রতিটি পরিবর্তনের পরে আবার মাপুন, কারণ critical path সরে যায়, যেমন e2e ছোট করতেই integ সামনে এলো।

মূল উপলব্ধি: সম্পদ বাড়ানো তখনই কাজে লাগে, যখন আপনি জানেন আসল সীমা কোথায়। বাধা চিহ্নিত না করে টাকা ঢালা pipeline-এও ঠিক ততটাই অপচয়, যতটা নির্মাণকাজে।

প্র ০২Cache তো শুধু গতির জিনিস। তাহলে একটি cache কীভাবে একটি জনপ্রিয় open-source package-এর release-এ ক্ষতিকর কোড পৌঁছে দিতে পারে?

Cache-কে নিরীহ ভাবার ভুলটিই আসল সমস্যা। প্রথাগত ভাবনায় বিশ্বাসের সীমা হলো “source code”: কোড review হয়, তাই কোড বিশ্বস্ত। কিন্তু build-এর ফল শুধু কোড থেকে আসে না। আসে কোড, dependency, tool আর cache মিলে। Cache যদি এমন কেউ লিখতে পারে, যাকে আপনি release-এর অধিকার দেননি, তাহলে সে পরোক্ষে release-এ হাত দিতে পারে।

ডিসেম্বর ২০২৪-এ জনপ্রিয় Python package ultralytics-এর কয়েকটি version-এ ক্ষতিকর কোড (cryptominer) পাওয়া যায়। প্রকাশিত বিশ্লেষণ অনুযায়ী ঘটনার শুরু একটি GitHub Actions workflow-এর দুর্বলতা থেকে, যেখানে pull request-এর ইনপুট নিরাপদভাবে সামলানো হয়নি, আর build cache-এর মাধ্যমে ক্ষতিকর জিনিস release workflow-এ পৌঁছায় বলে জানানো হয়। PyPI-র মতো জায়গায় প্রকাশ হওয়া package-টি বাইরে থেকে দেখতে বৈধ, কারণ তা বৈধ pipeline থেকেই তৈরি। বিবরণের জন্য আসল প্রতিবেদন দেখুন, এখানে শুধু শিক্ষাটুকু জরুরি। ২০২৪-এর দিকে নিরাপত্তা গবেষকেরা এই শ্রেণির ঝুঁকি নিয়ে বিস্তারিত লিখেছিলেন: default branch-এ চলা একটি দুর্বল workflow গোটা repository-র cache-কে ঝুঁকিতে ফেলতে পারে।

প্রতিরক্ষা একটি স্তরে নয়, কয়েকটি স্তরে। Cache-এ লেখার অধিকার সীমিত করুন। Release job-কে এমন cache থেকে আলাদা রাখুন, যা কম-বিশ্বস্ত job লিখতে পারে। Hash-সহ lockfile দিয়ে dependency যাচাই করুন। বাইরের ইনপুটকে shell-এ সরাসরি না বসান। আর SLSA-র মতো কাঠামো এজন্যই build-এর পরিবেশের আলাদা থাকাকে গুরুত্ব দেয়। Provenance থাকলে অন্তত বলা যায় package-টি কোন workflow-র কোন run থেকে এসেছে।

মূল উপলব্ধি: Pipeline-এ যা-কিছু build-এর ফলকে বদলাতে পারে, তা-ই আপনার trusted computing base-এর অংশ। Cache সেই তালিকায় আছে, গতির tool বলে সে ছাড় পায় না।

প্র ০৩Flaky test মুছে দিলেই তো ঝামেলা শেষ। বড় প্রকৌশল দলগুলো তা করে না কেন, আর retry কেন একই সাথে ওষুধ ও বিষ?

Flaky test-এর আসল ক্ষতি সময় নয়, বিশ্বাস। লাল pipeline-এর মানে যখন “হয়তো সত্যি, হয়তো না”, তখন মানুষ পরীক্ষা না করেই “re-run” চাপতে শেখেন। তারপর একদিন আসল ভুলটিও re-run-এ পাস করে যায়। নেকড়ের গল্পের রাখাল বালকের মতো, সংকেত যত মিথ্যা বলে, সত্য সংকেতের দামও তত কমে।

গবেষণায় এর ছবি পরিষ্কার। ২০১৪ সালে Luo ও সহলেখকেরা open-source project-এর flaky test-এর কারণ বিশ্লেষণ করে দেখান, async অপেক্ষা, concurrency আর test-এর ক্রমের ওপর নির্ভরতা বড় কারণের মধ্যে পড়ে। ২০১৬ সালে Google Testing Blog-এ John Micco লেখেন, সেখানে প্রায় ১.৫% test run flaky ফল দিত, আর প্রায় ১৬% test-এ কোনো না কোনো মাত্রায় flakiness ছিল। এই সংখ্যাগুলো সেই সময়ের একটি প্রতিষ্ঠানের; আপনার দলের জন্য নয়, তবে দেখায় যে বড় পরিসরে এটি দূর করা যায় না, শুধু সামলানো যায়।

মুছে দিলে কী হয়? Flaky test প্রায়ই সত্যিকারের কিছু ধরে: production-এও আছে এমন race condition, ধীর network-এ timeout, দুর্বল retry-র যুক্তি। Test মুছে দিলে আপনি থার্মোমিটার ফেলে দিচ্ছেন, জ্বর রয়েই গেল। আবার ধরে রাখলে সবাই আটকে থাকেন। Quarantine এই দুইয়ের মাঝের পথ: test চলে, তথ্য জমে, কিন্তু merge আটকায় না, আর একজন মালিক সময়সীমাসহ দায়িত্ব নেন।

Retry ওষুধ, কারণ অঙ্কে সে দুর্দান্ত: ৮৬.৫% মিথ্যা লাল ০.২%-এ নামে। বিষ, কারণ retry-তে পাস করা test কোনো চিহ্ন না রাখলে ধীরে ধীরে suite পচতে থাকে, আর pipeline-ও ধীর হয়। তাই retry দিন, সীমা রেখে, আর প্রতিটি “retry-তে পাস” একটি মেট্রিকে তুলুন।

মূল উপলব্ধি: Pipeline-এর মূল সম্পদ দলের বিশ্বাস। যে সংকেত মিথ্যা বলে, তাকে হয় ঠিক করতে হয়, নয় আলাদা করতে হয়। চুপচাপ ঢেকে রাখা চলে না।

প্র ০৪Self-hosted runner-এ cache দ্রুত, hardware পছন্দমতো, বিলও কম হতে পারে। তাহলে নিরাপত্তা নিয়ে এত সতর্কতা কেন, আর ephemeral runner কি সব সমস্যা মেটায়?

সমস্যার মূল নকশায়। CI-এর কাজই হলো অন্যের লেখা কোড চালানো, আর self-hosted runner সেই কোডকে আপনার নিজের জমিতে ঢুকতে দেয়। Hosted runner-এ ক্ষতি হলে সে ভেঙে পড়ে platform-এর একটি মুছে ফেলা VM-এ। আপনার নিজের যন্ত্রে হলে পাশে থাকে অন্য job-এর চিহ্ন, cloud-এর পরিচয়, হয়তো ভেতরের database-এর পথ।

২০২৪ সালের শুরুতে নিরাপত্তা গবেষকেরা (John Stawinski ও Adnan Khan প্রমুখ) জনসমক্ষে লিখেছিলেন, কয়েকটি বড় open-source project-এর self-hosted runner-এ fork-এর pull request-এর মাধ্যমে কীভাবে ঝুঁকি তৈরি হয়েছিল, যা তাঁরা দায়িত্বের সাথে জানান। এটি নির্দিষ্ট দুর্বলতার গল্প, কিন্তু শ্রেণিটি সাধারণ: public repository, অবিশ্বস্ত pull request আর স্থায়ী runner, এই তিনটি একসঙ্গে থাকলেই বিপদ। এজন্যই GitHub-এর documentation public repository-তে self-hosted runner নিয়ে সতর্ক করে।

Ephemeral runner অনেকটা মেটায়, সব নয়। এটি ঠেকায় এক job থেকে পরের job-এ ছড়ানো। কিন্তু চলন্ত job-এর ভেতরে যা আছে, তা ঠেকায় না। Job তখনো instance-এর পরিচয় ব্যবহার করতে পারে, private network-এ কথা বলতে পারে, আর ভুল করে দেওয়া Docker socket দিয়ে host-এ পৌঁছাতে পারে। আর “ephemeral” অর্থ যদি শুধু একই VM-এ নতুন process হয়, তাহলে ফাইল থেকেই যায়। প্রকৃত isolation লাগে VM বা শক্ত sandbox-এ।

তাই একটি পরিপক্ব নকশায় স্তর থাকে: job প্রতি নতুন VM, autoscaling-এ চলা runner, বিশ্বাসের স্তর অনুযায়ী আলাদা pool, ন্যূনতম IAM, সীমিত egress, আর কেন্দ্রীয় log. খরচের হিসাবেও মনে রাখুন, runner-কে patch আর পাহারা দেওয়ার মানুষের সময় বিলে লেখা থাকে না। নিজের গাড়ি কেনার আগে ড্রাইভার আর গ্যারেজের খরচও ধরতে হয়।

মূল উপলব্ধি: Self-hosted runner মানে আপনি একটি “যে কোনো কোড চালানোর সেবা” নিজে চালাচ্ছেন। সেটিকে production service-এর মতোই গুরুত্ব দিয়ে নকশা করতে হয়।

প্র ০৫Monorepo-তে path filter সহজ, Bazel-এর মতো build graph tool কঠিন। Path filter কোথায় থেমে যায়, আর বড় দলগুলো কেন graph-এর দিকে যায়?

Path filter প্রশ্ন করে “কোন file বদলেছে?” আসল প্রশ্ন “এই বদলে কোন কাজের ফল বদলাতে পারে?” দুটি প্রশ্নের মধ্যের ফাঁকেই সব সমস্যা। আপনি libs/money/ বদলালেন, আর services/checkout/-এর path filter কিছুই দেখল না, অথচ checkout ওই library ব্যবহার করে। Filter সবুজ, production লাল। এই ফাঁক বন্ধ করতে দলগুলো path-এর তালিকা হাতে লম্বা করতে থাকে, আর সেই তালিকা কোডের থেকে পিছিয়ে পড়ে।

Build graph tool উল্টো দিক থেকে শুরু করে। প্রতিটি target-এর input আর নির্ভরতা ঘোষণা করা থাকে। ফলে tool হিসাব করতে পারে, এই বদলে কোন কোন target-এর ফল বদলাতে পারে। সব input ঘোষিত হলে, target-এর ফল input-এর hash দিয়ে চেনা যায়, আর তখন cache একটি নতুন মাত্রা পায়: remote build cache. একজনের করা build-এর ফল সবাই পান, CI-ও। Google-এর ভেতরের Blaze থেকে আসা Bazel ২০১৫ সালে open-source হয়। ২০১৬ সালে Potvin ও Levenberg-এর CACM লেখায় Google-এর monorepo-র বিবরণ পাওয়া যায়। JavaScript জগতে Nx আর Turborepo একই ধারণাকে সহজ রূপ দেয়।

দাম কম নয়। সব build-কে hermetic করতে হয়, মানে অঘোষিত কিছুর ওপর নির্ভর করা চলবে না। প্রতিটি target-এর জন্য ঘোষণার file লিখতে হয়। শেখার খরচ আছে, আর এই ব্যবস্থা দেখাশোনার জন্য প্রায়ই আলাদা মানুষ লাগে। আর একটি নিরাপত্তার কথা: remote cache-ও cache, তাই poisoning-এর সব প্রশ্ন এখানেও প্রযোজ্য, শুধু পরিসর আরও বড়।

তাহলে বেছে নেবেন কীভাবে? ছোট monorepo, কয়েকটি service আর পরিষ্কার সীমানা হলে path filter, সাথে রাতের পুরো suite যথেষ্ট। যখন হাতে লেখা তালিকা নিয়মিত ভুল করছে, বা build-এর সময় মোট বিলের বড় অংশ হয়ে উঠছে, তখন graph tool-এর দাম উঠে আসে।

মূল উপলব্ধি: সঠিকভাবে কম কাজ করতে হলে নির্ভরতা জানতে হয়। Path হলো নির্ভরতার অনুমান, graph হলো তার ঘোষণা। অনুমান সস্তা, ঘোষণা দামি, আর কোনটি দরকার তা ঠিক করে ভুলের খরচ।

অনুশীলন

Cloud account লাগবে না। প্রথম তিনটি কাগজ-কলমে, চতুর্থটি পাতার simulator-এ।

  1. হিসাব: Job-গুলো: A ২ মিনিট; B ৪ (A-র পরে); C ৩ (A-র পরে); D ৫ (B-র পরে); E ২ (C আর D-র পরে)। (ক) মোট কাজ, critical path কত? (খ) দুটি runner-এ সময় কত? (গ) C-কে ১ মিনিটে নামালে কী বদলাবে?

    (ক) মোট কাজ ২ + ৪ + ৩ + ৫ + ২ = ১৬ মিনিট। পথ A→B→D→E = ১৩ মিনিট; A→C→E = ৭ মিনিট। Critical path ১৩ মিনিট।

    (খ) দুটি runner-এই ১৩ মিনিট। মিনিট ০–২ A; ২-তে B আর C একসঙ্গে; C মিনিট ৫-এ, B মিনিট ৬-এ শেষ; D ৬–১১; E ১১–১৩। কখনোই দুটির বেশি job একসঙ্গে চলে না। সীমা দুটি: ১৩ আর ১৬ ÷ ২ = ৮, বড়টি ১৩।

    (গ) কিছুই না। C critical path-এ নেই, তার ৬ মিনিটের ঢিল আছে। শুধু runner-বিল ২ মিনিট কমবে।

  2. নকশা ও সমালোচনা: একটি দলের pipeline: public repository, স্থায়ী self-hosted runner, cache key deps-${{ github.sha }}, সব job একটি stage-এ পরপর, deploy job-এ cancel-in-progress: true, secret সব job-এ। অন্তত পাঁচটি সমস্যা ধরুন, আর প্রতিটির সমাধান দিন।
    • Public + স্থায়ী self-hosted: fork-এর কোড আপনার যন্ত্রে। সমাধান: hosted runner, অথবা শুধু বিশ্বস্ত branch-এ আলাদা ephemeral pool.
    • Key-তে commit SHA: প্রতি commit-এ নতুন key, প্রায় সব miss. সমাধান: OS + lockfile-এর hash.
    • সব পরপর: সময় মোট কাজের সমান, fail fast নেই। সমাধান: needs দিয়ে DAG, দ্রুত check আগে।
    • Deploy বাতিল হওয়া: আধখানা deploy. সমাধান: cancel-in-progress: false, একই environment-এ একবারে একটি।
    • সব job-এ secret: বড় ক্ষতির পরিসর। সমাধান: শুধু deploy job-এ, environment-এর সীমায়, OIDC-তে।
  3. আউটপুট অনুমান: একটি Dockerfile-এর ক্রম: FROM, WORKDIR, COPY requirements.txt ., RUN pip install …, COPY app.py .. আগের build-এর layer cache আছে। এবার শুধু requirements.txt-এ একটি লাইন যোগ করে আবার build করা হলো। কোন ধাপ CACHED, কোনটি সত্যি চলবে?

    FROM আর WORKDIR CACHED. COPY requirements.txt-এর input বদলেছে, তাই সে চলবে। তার পরের RUN pip install আর COPY app.py-ও চলবে, যদিও app.py একই আছে, কারণ layer cache ভাঙলে পরের সব ধাপ ভাঙে। এই পর্বের আসল run-এও pip step আবার চলেছিল।

  4. Simulator নিয়ে পরীক্ষা: test_sharding.js-এ SHARDS ৮ করুন, আর SETUP ১২০ করুন। অনুমান করে নিন: balanced ভাগে গতি বাড়বে না কমবে, Amdahl-এর ছাদ কোথায় সরবে? তারপর চালিয়ে মিলিয়ে নিন।

    দুই বদলে program দেখায়: এক runner-এ ১,০০০ সেকেন্ড, balanced ভাগে shard-এর চাপ ১০৬ থেকে ১১২ সেকেন্ড, মোট ২৩২ সেকেন্ড, গতি ৪.৩১ গুণ। Naive ভাগে গতি ২.৩৫ গুণ। SETUP বাড়ায় ভাগ-না-হওয়া অংশ বেড়েছে: p = ৮৮০ ÷ ১,০০০ = ০.৮৮, তাই ছাদ ১ ÷ ০.১২ ≈ ৮.৩৩ গুণ (program-এ n = ১,০০০-এ ৮.২৭), আগের ১৫.৭ গুণের অনেক নিচে। Runner-সেকেন্ড ১,৮৪০। শিক্ষা: setup কমানো, মানে cache, অনেক সময় shard বাড়ানোর চেয়ে বেশি লাভ দেয়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি ধীর pipeline দ্রুত করার যুক্তিসঙ্গত ক্রম সাজান (▲▼ চেপে সরান)

  1. Queue time আর p50, p90 duration মাপা
  2. DAG এঁকে critical path বের করা
  3. Dockerfile-এর ক্রম আর cache key ঠিক করা
  4. Critical path-এর test সময় ধরে shard করা
  5. আবার মেপে নতুন critical path খোঁজা

একটি pipeline-এর মোট কাজ ৬০ মিনিট, critical path ২৪ মিনিট। দশটি runner থাকলে, আর queue না থাকলে, সবচেয়ে কম কত সময় লাগতে পারে?

  • ৬ মিনিট
  • ২৪ মিনিট
  • ৩০ মিনিট
  • ৬০ মিনিট

একটি Dockerfile-এ COPY . . থাকে RUN pip install-এর আগে। প্রতি commit-এ শুধু কোড বদলায়। ফল কী হবে?

  • Install-এর layer cache থেকে আসবে
  • শুধু image-এর আকার দ্বিগুণ হবে
  • প্রতি commit-এ install আবার চলবে
  • Docker নিজে ক্রম ঠিক করে নেবে

৩০ মিনিটের একটি suite, প্রতিটি runner-এ setup ৩ মিনিট, ৬টি shard, ভাগ নিখুঁত। সময় আর মোট runner-মিনিট কত?

  • ৮ মিনিট, ৪৮ runner-মিনিট
  • ৫ মিনিট, ৩০ runner-মিনিট
  • ৮ মিনিট, ৩৩ runner-মিনিট
  • ২৩ মিনিট, ৪৮ runner-মিনিট

Cache-এর একটি মডেলে cache ছাড়া install ৪ মিনিট, hit-এ ০.৫ মিনিট, আর miss-এ ৪.৫ মিনিট। Hit ratio কত হলে cache লাভ-ক্ষতির সমান?

  • ৫০%
  • ২৫%
  • ০%
  • ১২.৫%

Cache poisoning কমাতে সবচেয়ে কার্যকর নকশা কোনটি?

  • Key-কে লম্বা আর এলোমেলো করা
  • লেখার অধিকার সীমিত করা, hash মিলিয়ে install, release-এ আলাদা পরিবেশ
  • Cache-এর মেয়াদ বাড়ানো
  • Fork-এর PR-কে সব cache-এ লিখতে দেওয়া

২,০০০টি test, প্রতিটি ০.১% সম্ভাবনায় মিথ্যা ফেল করে (স্বাধীনভাবে)। প্রায় কত শতাংশ run অকারণে লাল হবে?

  • ০.১%
  • ০.২%
  • ৮৬.৫%
  • ১০০%

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Pipeline একটি DAG; সময়ের নিচের সীমা critical path, আর বিল ঠিক করে মোট কাজ।
  2. আগে মাপুন: queue বড় হলে capacity, নইলে critical path-এর job.
  3. Cache না থাকলেও ফল এক; key = OS + lockfile-এর hash; Dockerfile-এ lockfile আগে, কোড পরে।
  4. Sharding সময় ধরে ভাগ করুন; setup Amdahl-এর ছাদ বসায়; flaky test চিহ্নিত ও quarantine করুন।
  5. Pipeline একটি নিরাপত্তার সীমানা: self-hosted runner, cache, fork-এর PR আর secret, সবখানে অবিশ্বস্ত কোড আলাদা রাখুন।

পরের পর্বে: Pipeline চলছে, এবার তার ফল। একই image কীভাবে একবার build হয়ে staging থেকে production-এ যায়, digest দিয়ে কীভাবে চেনা হয়, আর কেন প্রতি environment-এ নতুন build বিপজ্জনক।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান