ゼロダウンタイムデプロイを1年間継続して得た、6つの教訓
1分の停止が平均数十万円の損失につながることは、多くの業界で知られています。¹ 研究データではデプロイ頻度が高い組織ほど復旧が速く、変更失敗率も低い傾向が示されています。² 本記事では、ゼロダウンタイムを目指すうえで有効とされる設計と運用の考え方を、CTOの視点で「6つの教訓」に整理し、実装のディテールとビジネスへの示唆の両面から解説します。ここで述べる内容は、一般に知られたプラクティスや公開情報に基づく提案であり、特定の組織の実績値ではありません。
前提と環境:ゼロダウンタイムのための土台
ここで扱う前提は、AWS上のマネージドKubernetes(EKS)にNGINXをIngress(外部からの入口となるゲートウェイ)として採用し、アプリケーションはHTTPとgRPCを併用する構成です。データベースはPostgreSQL、キャッシュはRedis、キューはSQS互換で運用します。CI/CDはGitHub ActionsからArgo CDへ反映し、メトリクスはPrometheusとGrafana、分散トレースはOpenTelemetryで収集する例を想定しています。ゼロダウンタイムを支える考え方は単純で、トラフィックの切り替えを上手に行い、実行中リクエストを正しく最後まで面倒を見て、スキーマやステートの互換性を切らさないことに尽きます。以降の教訓は、まさにその3点をどうやって現実の制約の中で回し続けるか、という観点で整理しています。
6つの教訓:一年運用で確信に変わったこと
教訓1:ヘルスチェックは「生存」と「準備完了」を分け、ドレインと同期させる
ゼロダウンタイムの入口はヘルスチェックです。生存確認(liveness)はプロセスが起きているかの監視に徹し、準備完了(readiness)は依存先とアプリの起動完了を厳密に見ます。³ さらにロードバランサの接続ドレイン(既存接続を丁寧に切り上げる)とpreStopフック(コンテナ停止前のフック)を同期させることで、切替時のコネクション断を最小化しやすくなります。以下はNGINXのコネクションドレイン設定と、アプリ側のヘルスエンドポイントの例です。
upstream app_backend {
server app-v1:8080 max_fails=1 fail_timeout=10s;
server app-v2:8080 max_fails=1 fail_timeout=10s;
keepalive 256;
}
server {
listen 443 ssl http2;
location /healthz {
proxy_pass http://app_backend;
proxy_connect_timeout 1s;
proxy_read_timeout 1s;
}
location / {
proxy_pass http://app_backend;
proxy_next_upstream timeout http_502 http_503;
proxy_next_upstream_tries 1;
}
}
import express from 'express';
import pg from 'pg';
import Redis from 'ioredis';
const app = express();
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
let ready = false;
app.get('/live', (_req, res) => res.status(200).send('ok'));
app.get('/ready', async (_req, res) => {
try {
await Promise.race([
db.query('SELECT 1'),
redis.ping(),
new Promise((_r, reject) => setTimeout(() => reject(new Error('timeout')), 800))
]);
if (!ready) throw new Error('booting');
res.status(200).send('ready');
} catch (e) {
res.status(503).send('not ready');
}
});
async function bootstrap() {
await db.query('SELECT 1');
await redis.ping();
ready = true;
}
process.on('SIGTERM', () => setTimeout(() => process.exit(0), 15000));
bootstrap().then(() => app.listen(8080));
生存と準備完了の混同は、観測や障害対応の判断を誤らせます。役割を分け、ドレインと同期をとるだけでも、切替時の体験は安定します。
教訓2:ローリングのパラメータは指標で決め、p95を見ながら段階的に上げる
ローリングアップデートのチューニングは、maxUnavailableを0に固定し、maxSurgeを負荷とp95レイテンシ(95パーセンタイルの応答時間)で段階的に調整するのが安定しやすい設計です。readinessProbeは軽すぎると意味がなく、実際の依存を叩くことが重要です。preStopでリクエスト完了を待ち、terminationGracePeriodは実測に合わせて見直します。
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
replicas: 8
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: web
image: registry/app:1.2.3
ports:
- containerPort: 8080
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 2
timeoutSeconds: 1
failureThreshold: 2
livenessProbe:
httpGet:
path: /live
port: 8080
periodSeconds: 5
timeoutSeconds: 1
デプロイ中はSLOやp95の推移を観察し、スパイクが出ない範囲でmaxSurgeを引き上げるのが安全です。段階的に試し、観測で裏付ける運用が肝要です。
教訓3:データベースは拡張・移行・切替・縮退の順(expand/contract)でしか守れない
スキーマ互換性が切れた瞬間にゼロダウンタイムは消えます。列追加は必ず互換な形で行い、アプリは**同時書き込み(dual write)**で新旧を埋め、切替後に旧列を落とすのが基本線です。以下のように、NULL許容で列を追加し、アプリから新列へ書き込みを始め、バッチでバックフィル、読み取りを切替、最後に旧列を削除します。⁴
-- expand
ALTER TABLE orders ADD COLUMN total_cents_new bigint;
-- dual write(アプリ側で実装)
-- backfill(バッチで分割実行)
UPDATE orders SET total_cents_new = total_cents WHERE total_cents_new IS NULL LIMIT 10000;
-- read switch(アプリ側でフラグ切替)
-- contract(安定後に実行)
ALTER TABLE orders DROP COLUMN total_cents;
ALTER TABLE orders RENAME COLUMN total_cents_new TO total_cents;
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function backfillBatch(batchSize = 5000) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
'UPDATE orders SET total_cents_new = total_cents WHERE total_cents_new IS NULL LIMIT $1',
[batchSize]
);
await client.query('COMMIT');
return rowCount;
} catch (e) {
await client.query('ROLLBACK');
console.error('backfill error', e);
return 0;
} finally {
client.release();
}
}
(async () => {
let updated;
do {
updated = await backfillBatch();
await new Promise(r => setTimeout(r, 200));
} while (updated > 0);
})();
この手順により、スキーマ変更を含むリリースでもAPI互換性を保ちやすくなり、ユーザ影響を最小化できます。
教訓4:機能の出荷と露出を分離し、フィーチャーフラグで安全に広げる
コードを出すこととユーザに見せることを分けると、ゼロダウンタイムの難易度は下がります。フィーチャーフラグで小さく有効化し、計測しながら段階的に拡大します。以下はシンプルなフラグラッパーの例です。外部サービスを使う場合も、復旧時の振る舞いを安全側に倒す設計が効きます。⁵
import { EventEmitter } from 'events';
type Flags = { [key: string]: boolean };
class FeatureFlags extends EventEmitter {
private flags: Flags = {};
isOn(key: string): boolean {
return !!this.flags[key];
}
set(key: string, value: boolean) {
this.flags[key] = value;
this.emit('change', key, value);
}
}
export const flags = new FeatureFlags();
export function showNewCheckout(): boolean {
return flags.isOn('checkout_v2');
}
// フラグ未取得時は旧挙動にフォールバック(安全側)
この運用は、段階リリース時のエラー検知と即時停止を容易にし、変更失敗時のユーザ影響範囲を限定するのに役立ちます。
教訓5:セッションとステートは外出し、長時間接続はバージョン付きで切り替える
プロセスメモリにステートを持つ構成は、ローリング時にセッション喪失を引き起こします。セッションはRedisなどへ外出しし、WebSocketやストリーミングの長時間接続はバージョン付きエンドポイントにして徐々に移すと安全です。
import session from 'express-session';
import connectRedis from 'connect-redis';
import Redis from 'ioredis';
const RedisStore = connectRedis(session);
const redisClient = new Redis(process.env.REDIS_URL);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 7 * 24 * 3600 * 1000, secure: true }
}));
// WebSocketは /ws/v1 と /ws/v2 を並走させ、サーバ側でboth対応
こうした分離により、ローリング中の認証状態の喪失や長時間接続の強制切断を避けやすくなります。
教訓6:観測可能性が9割。デプロイは計測し、パイプラインで自動判定する
ダウンタイムを防ぐ最後の守りは観測可能性です。デプロイ単位にトレースとメトリクスをひも付け、しきい値を越えたら自動停止・ロールバックする仕組みをCIに組み込みます。以下はGitHub Actionsでカナリア、スモークテスト、ロールバックを行う例です。
name: deploy
on:
push:
branches: [ main ]
jobs:
canary:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy 10%
run: kubectl apply -f k8s/canary.yaml
- name: Smoke
run: |
for i in {1..20}; do curl -fsS https://api.example.com/healthz && break || sleep 3; done
- name: Check SLO
run: node ./scripts/check-slo.js --window 5m --p95 < 120ms --errorRate < 0.2
- name: Promote or Rollback
run: |
if node scripts/check-slo.js --window 5m --p95 < 120ms --errorRate < 0.2; then \
kubectl apply -f k8s/stable.yaml; \
else \
kubectl rollout undo deploy/app; exit 1; \
fi
OpenTelemetryのデプロイID属性でトレースを束ねると、変更がレイテンシやエラーに与える影響が可視化され、人的判断の迷いを減らす助けになります。
数値で振り返る一年:技術が生むビジネス効果
ゼロダウンタイムを目指す運用の効果は、定量で振り返るのが有効です。評価軸の例として、デプロイ所要時間、1日あたりのデプロイ回数、変更失敗率、MTTR(平均復旧時間)、オンコール回数、A/Bテストの回転速度、機能の市場投入リードタイムなどがあります。これらは組織ごとに前提が異なるため、固定値ではなくベースラインからの相対改善で捉えるのが現実的です。一般に、継続的デリバリの実践により、待機や手戻りの削減、新機能開発への工数再配分、主要ファネルの改善サイクル加速といった効果が期待できます。²
品質の観点では、ユーザ報告のうちデプロイ起因の不具合の割合を下げ、夜間帯のオンコール頻度を抑えることが、士気と採用の双方に効きます。社内外の説明に耐える指標設計(定義の明確化、計測手段の整備、公開可能な根拠の用意)をセットで進めると、信頼できる意思決定が回り始めます。
まとめ:ゼロダウンタイムは「技術×運用×観測」の合成
ゼロダウンタイムを継続するうえで見えてくるのは、個々のテクニックではなく、技術と運用と観測を地道に噛み合わせる姿勢でした。ヘルスチェックの設計、ローリングのパラメータ、DB移行の順序、フラグ運用、ステート外出し、そして自動判定までを通貫させると、初めて「止めない」文化が根づきます。明日からできることとして、準備完了の定義を実サービス準拠に見直し、preStopとドレインの同期を入れ、Expand/Contractの手順書をチームで共有するだけでも、効果が期待できます。
あなたのプロダクトにおいて、今もっともゼロダウンタイムを阻んでいるのはどこでしょうか。ヘルスチェックの粒度か、DB互換性か、それとも観測の不足か。今日のリリースに一つだけ改善を混ぜるとしたら、何を選びますか。小さく確実な一歩を積み重ねることで、止まらない開発と止めない価値提供に近づけるはずです。